mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
46 Commits
feature/ta
...
v0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
548307336a | ||
|
|
f534f90ca7 | ||
|
|
0290013d19 | ||
|
|
85f4a3049c | ||
|
|
4c27f1a2de | ||
|
|
69d3ff4f7b | ||
|
|
2b1da883fb | ||
|
|
c291170d79 | ||
|
|
d9925d632e | ||
|
|
ddf11b394d | ||
|
|
129c132f97 | ||
|
|
26637e6f84 | ||
|
|
612f40a4b0 | ||
|
|
8f34fac0a6 | ||
|
|
a374a52474 | ||
|
|
09586de2d5 | ||
|
|
678d27c93a | ||
|
|
7af1990935 | ||
|
|
2372ee0622 | ||
|
|
f147fb9725 | ||
|
|
d5a4df738f | ||
|
|
b3b84db004 | ||
|
|
d3d3a98b08 | ||
|
|
9f2ac70549 | ||
|
|
071895791c | ||
|
|
9a50e144b3 | ||
|
|
a77b8a5ed2 | ||
|
|
fcba96fbf6 | ||
|
|
d200de54a8 | ||
|
|
a882ec6ca0 | ||
|
|
3ee7839ca9 | ||
|
|
165391861a | ||
|
|
8d950c6d45 | ||
|
|
6c7b1b3f21 | ||
|
|
51cd919db6 | ||
|
|
9d279d4980 | ||
|
|
32c7e55a15 | ||
|
|
084647c2a6 | ||
|
|
469f128604 | ||
|
|
c9c221de62 | ||
|
|
878bbd359d | ||
|
|
a6528102fe | ||
|
|
bff766d363 | ||
|
|
2e8da98287 | ||
|
|
a820d8540f | ||
|
|
78ea8a673b |
@@ -1,4 +1,6 @@
|
||||
APP_NAME=solidtime
|
||||
APP_VERSION=0.0.0
|
||||
APP_BUILD=0
|
||||
VITE_APP_NAME=solidtime
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
|
||||
46
.github/workflows/build-private.yml
vendored
46
.github/workflows/build-private.yml
vendored
@@ -20,15 +20,55 @@ jobs:
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
- name: "Get build"
|
||||
id: build
|
||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Get Previous tag (normal push)"
|
||||
id: previoustag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
prefix: "v"
|
||||
|
||||
- name: "Get version"
|
||||
id: version
|
||||
run: |
|
||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ERROR: No previous tag found";
|
||||
exit 1;
|
||||
fi
|
||||
else
|
||||
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: "Copy .env template for production"
|
||||
run: |
|
||||
cp .env.production .env
|
||||
rm .env.production .env.ci .env.example
|
||||
|
||||
- name: "Add version to .env"
|
||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
|
||||
|
||||
- name: "Add build to .env"
|
||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
|
||||
|
||||
- name: "Output .env"
|
||||
run: cat .env
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Copy .env template for production"
|
||||
run: cp .env.production .env && cat .env
|
||||
|
||||
- name: "Checkout billing extension"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
90
.github/workflows/build-public-release.yml
vendored
90
.github/workflows/build-public-release.yml
vendored
@@ -1,90 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-public.yml'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
name: Build - Public (Release)
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "Copy .env template for production"
|
||||
run: cp .env.production .env
|
||||
|
||||
- name: "Install dependencies"
|
||||
uses: php-actions/composer@v6
|
||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||
with:
|
||||
command: install
|
||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
php_version: 8.3
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Install npm dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: "Build"
|
||||
run: npm run build
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
solidtime/solidtime
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Build and push"
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
44
.github/workflows/build-public.yml
vendored
44
.github/workflows/build-public.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-public.yml'
|
||||
@@ -23,9 +25,49 @@ jobs:
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
- name: "Get build"
|
||||
id: build
|
||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Get Previous tag (normal push)"
|
||||
id: previoustag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
prefix: "v"
|
||||
|
||||
- name: "Get version"
|
||||
id: version
|
||||
run: |
|
||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ERROR: No previous tag found";
|
||||
exit 1;
|
||||
fi
|
||||
else
|
||||
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: "Copy .env template for production"
|
||||
run: cp .env.production .env
|
||||
run: |
|
||||
cp .env.production .env
|
||||
rm .env.production .env.ci .env.example
|
||||
|
||||
- name: "Add version to .env"
|
||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
|
||||
|
||||
- name: "Add build to .env"
|
||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
|
||||
|
||||
- name: "Output .env"
|
||||
run: cat .env
|
||||
|
||||
- name: "Install dependencies"
|
||||
uses: php-actions/composer@v6
|
||||
|
||||
@@ -45,7 +45,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->where('is_placeholder', '=', false);
|
||||
}),
|
||||
@@ -82,7 +82,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
}
|
||||
$user = null;
|
||||
$organization = null;
|
||||
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) {
|
||||
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
|
||||
@@ -35,7 +35,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
(new UniqueEloquent(User::class, 'email'))->ignore($user->id)->query(function (Builder $query) {
|
||||
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
}),
|
||||
|
||||
@@ -38,7 +38,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
|
||||
AddingTeamMember::dispatch($organization, $newOrganizationMember);
|
||||
|
||||
DB::transaction(function () use ($organization, $newOrganizationMember, $role) {
|
||||
DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
|
||||
$organization->users()->attach(
|
||||
$newOrganizationMember, ['role' => $role]
|
||||
);
|
||||
@@ -71,10 +71,10 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
(new ExistsEloquent(User::class, 'email', function (Builder $builder) {
|
||||
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->where('is_placeholder', '=', false);
|
||||
}))->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
@@ -93,7 +93,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
*/
|
||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
||||
{
|
||||
return function ($validator) use ($team, $email) {
|
||||
return function ($validator) use ($team, $email): void {
|
||||
$validator->errors()->addIf(
|
||||
$team->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\SelfHost;
|
||||
|
||||
use App\Service\ApiService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SelfHostCheckForUpdateCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'self-host:check-for-update';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$apiService = app(ApiService::class);
|
||||
|
||||
$latestVersion = $apiService->checkForUpdate();
|
||||
if ($latestVersion === null) {
|
||||
$this->error('Failed to check for update, check the logs for more information.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Note: Cache for 13 hours, because the command runs twice daily (every 12 hours).
|
||||
Cache::put('latest_version', $latestVersion, 60 * 60 * 12);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
44
app/Console/Commands/SelfHost/SelfHostTelemetryCommand.php
Normal file
44
app/Console/Commands/SelfHost/SelfHostTelemetryCommand.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\SelfHost;
|
||||
|
||||
use App\Service\ApiService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SelfHostTelemetryCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'self-host:telemetry';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$apiService = app(ApiService::class);
|
||||
|
||||
$success = $apiService->telemetry();
|
||||
|
||||
if (! $success) {
|
||||
$this->error('Failed to send telemetry data, check the logs for more information.');
|
||||
|
||||
return self::FAILURE;
|
||||
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
|
||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, TimeEntry> $timeEntries */
|
||||
foreach ($timeEntries as $timeEntry) {
|
||||
$user = $timeEntry->user;
|
||||
|
||||
@@ -17,6 +17,14 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->command('time-entry:send-still-running-mails')
|
||||
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
||||
->everyTenMinutes();
|
||||
|
||||
$schedule->command('self-host:check-for-update')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||
->twiceDaily();
|
||||
|
||||
$schedule->command('self-host:telemetry')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
||||
->twiceDaily();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,7 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->reportable(function (Throwable $e) {
|
||||
$this->reportable(function (Throwable $e): void {
|
||||
//
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Extensions\Scramble;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
|
||||
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
|
||||
use Dedoc\Scramble\Support\Generator\Response;
|
||||
use Dedoc\Scramble\Support\Generator\Schema;
|
||||
@@ -44,39 +45,49 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = new OpenApiObjectType;
|
||||
$type->addProperty('data', (new ArrayType)->setItems($collectingType));
|
||||
$type->addProperty(
|
||||
'links',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('first', (new StringType)->nullable(true))
|
||||
->addProperty('last', (new StringType)->nullable(true))
|
||||
->addProperty('prev', (new StringType)->nullable(true))
|
||||
->addProperty('next', (new StringType)->nullable(true))
|
||||
->setRequired(['first', 'last', 'prev', 'next'])
|
||||
);
|
||||
$type->addProperty(
|
||||
'meta',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('current_page', new IntegerType)
|
||||
->addProperty('from', (new IntegerType)->nullable(true))
|
||||
->addProperty('last_page', new IntegerType)
|
||||
->addProperty('links', (new ArrayType)->setItems(
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('url', (new StringType)->nullable(true))
|
||||
->addProperty('label', new StringType)
|
||||
->addProperty('active', new BooleanType)
|
||||
->setRequired(['url', 'label', 'active'])
|
||||
)->setDescription('Generated paginator links.'))
|
||||
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
|
||||
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
|
||||
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
|
||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
|
||||
);
|
||||
$type->setRequired(['data', 'links', 'meta']);
|
||||
$newType = new OpenApiObjectType;
|
||||
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
|
||||
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
|
||||
$newType->addProperty(
|
||||
'meta',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||
->setRequired(['total'])
|
||||
);
|
||||
$newType->setRequired(['data', 'meta']);
|
||||
} else {
|
||||
$newType->addProperty(
|
||||
'links',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('first', (new StringType)->nullable(true))
|
||||
->addProperty('last', (new StringType)->nullable(true))
|
||||
->addProperty('prev', (new StringType)->nullable(true))
|
||||
->addProperty('next', (new StringType)->nullable(true))
|
||||
->setRequired(['first', 'last', 'prev', 'next'])
|
||||
);
|
||||
$newType->addProperty(
|
||||
'meta',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('current_page', new IntegerType)
|
||||
->addProperty('from', (new IntegerType)->nullable(true))
|
||||
->addProperty('last_page', new IntegerType)
|
||||
->addProperty('links', (new ArrayType)->setItems(
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('url', (new StringType)->nullable(true))
|
||||
->addProperty('label', new StringType)
|
||||
->addProperty('active', new BooleanType)
|
||||
->setRequired(['url', 'label', 'active'])
|
||||
)->setDescription('Generated paginator links.'))
|
||||
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
|
||||
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
|
||||
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
|
||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
|
||||
);
|
||||
$newType->setRequired(['data', 'links', 'meta']);
|
||||
}
|
||||
|
||||
return $type;
|
||||
return $newType;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,7 @@ class OrganizationResource extends Resource
|
||||
'nullable',
|
||||
'integer',
|
||||
'gt:0',
|
||||
'max:2147483647',
|
||||
])
|
||||
->numeric(),
|
||||
Forms\Components\DateTimePicker::make('created_at')
|
||||
@@ -122,7 +123,7 @@ class OrganizationResource extends Resource
|
||||
->persistent()
|
||||
->send();
|
||||
|
||||
return response()->streamDownload(function () use ($file) {
|
||||
return response()->streamDownload(function () use ($file): void {
|
||||
echo Storage::disk(config('filesystems.private'))->get($file);
|
||||
}, 'export.zip');
|
||||
} catch (\Exception $exception) {
|
||||
@@ -137,7 +138,7 @@ class OrganizationResource extends Resource
|
||||
}),
|
||||
Action::make('Import')
|
||||
->icon('heroicon-o-inbox-arrow-down')
|
||||
->action(function (Organization $record, array $data) {
|
||||
->action(function (Organization $record, array $data): void {
|
||||
try {
|
||||
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
||||
if ($file === null) {
|
||||
|
||||
@@ -29,6 +29,7 @@ class ProjectMemberResource extends Resource
|
||||
'nullable',
|
||||
'integer',
|
||||
'gt:0',
|
||||
'max:2147483647',
|
||||
])
|
||||
->numeric(),
|
||||
Forms\Components\Select::make('user_id')
|
||||
|
||||
@@ -45,6 +45,7 @@ class ProjectResource extends Resource
|
||||
'nullable',
|
||||
'integer',
|
||||
'gt:0',
|
||||
'max:2147483647',
|
||||
])
|
||||
->numeric(),
|
||||
Forms\Components\Select::make('organization_id')
|
||||
|
||||
38
app/Filament/Widgets/ServerOverview.php
Normal file
38
app/Filament/Widgets/ServerOverview.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ServerOverview extends Widget
|
||||
{
|
||||
protected static string $view = 'filament.widgets.server-overview';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
/** @var string|null $currentVersion */
|
||||
$currentVersion = config('app.version');
|
||||
/** @var string|null $build */
|
||||
$build = config('app.build');
|
||||
$latestVersion = Cache::get('latest_version', null);
|
||||
|
||||
$needsUpdate = false;
|
||||
if ($latestVersion !== null && $currentVersion !== null && version_compare($latestVersion, $currentVersion) > 0) {
|
||||
$needsUpdate = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $currentVersion,
|
||||
'build' => $build,
|
||||
'environment' => config('app.env'),
|
||||
'currentVersion' => $latestVersion,
|
||||
'needsUpdate' => $needsUpdate,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
@@ -43,4 +44,9 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
return $this->permissionStore->has($organization, $permission);
|
||||
}
|
||||
|
||||
protected function canAccessPremiumFeatures(Organization $organization): bool
|
||||
{
|
||||
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||
use App\Models\Organization;
|
||||
@@ -23,7 +24,9 @@ class OrganizationController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:view');
|
||||
|
||||
return new OrganizationResource($organization);
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
return new OrganizationResource($organization, $showBillableRate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +42,9 @@ class OrganizationController extends Controller
|
||||
|
||||
$organization->name = $request->input('name');
|
||||
$oldBillableRate = $organization->billable_rate;
|
||||
if ($request->has('employees_can_see_billable_rates')) {
|
||||
$organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates');
|
||||
}
|
||||
$organization->billable_rate = $request->getBillableRate();
|
||||
$organization->save();
|
||||
|
||||
@@ -46,6 +52,6 @@ class OrganizationController extends Controller
|
||||
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
||||
}
|
||||
|
||||
return new OrganizationResource($organization);
|
||||
return new OrganizationResource($organization, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Project\ProjectIndexRequest;
|
||||
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
||||
@@ -13,6 +14,7 @@ use App\Http\Resources\V1\Project\ProjectResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\BillableRateService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -60,7 +62,9 @@ class ProjectController extends Controller
|
||||
|
||||
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new ProjectCollection($projects);
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
return new ProjectCollection($projects, $showBillableRate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,9 +78,12 @@ class ProjectController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:view', $project);
|
||||
|
||||
// Note: There is currently no need to check if a user is a member of the project,
|
||||
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
|
||||
|
||||
$project->load('organization');
|
||||
|
||||
return new ProjectResource($project);
|
||||
return new ProjectResource($project, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,10 +102,13 @@ class ProjectController extends Controller
|
||||
$project->is_billable = (bool) $request->input('is_billable');
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
$project->client_id = $request->input('client_id');
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$project->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
$project->organization()->associate($organization);
|
||||
$project->save();
|
||||
|
||||
return new ProjectResource($project);
|
||||
return new ProjectResource($project, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,16 +127,29 @@ class ProjectController extends Controller
|
||||
if ($request->has('is_archived')) {
|
||||
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||
}
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$project->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
$oldBillableRate = $project->billable_rate;
|
||||
$clientIdChanged = false;
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
$project->client_id = $request->input('client_id');
|
||||
if ($project->client_id !== $request->input('client_id')) {
|
||||
$project->client_id = $request->input('client_id');
|
||||
$clientIdChanged = true;
|
||||
}
|
||||
$project->save();
|
||||
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||
}
|
||||
if ($clientIdChanged) {
|
||||
TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereBelongsTo($project, 'project')
|
||||
->update(['client_id' => $project->client_id]);
|
||||
}
|
||||
|
||||
return new ProjectResource($project);
|
||||
return new ProjectResource($project, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,8 +170,8 @@ class ProjectController extends Controller
|
||||
throw new EntityStillInUseApiException('project', 'time_entry');
|
||||
}
|
||||
|
||||
DB::transaction(function () use (&$project) {
|
||||
$project->members->each(function (ProjectMember $member) {
|
||||
DB::transaction(function () use (&$project): void {
|
||||
$project->members->each(function (ProjectMember $member): void {
|
||||
$member->delete();
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ class TaskController extends Controller
|
||||
$task = new Task;
|
||||
$task->name = $request->input('name');
|
||||
$task->project_id = $request->input('project_id');
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$task->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
$task->organization()->associate($organization);
|
||||
$task->save();
|
||||
|
||||
@@ -96,6 +99,9 @@ class TaskController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:update', $task);
|
||||
$task->name = $request->input('name');
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$task->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
if ($request->has('is_done')) {
|
||||
$task->done_at = $request->getIsDone() ? Carbon::now() : null;
|
||||
}
|
||||
|
||||
@@ -7,15 +7,19 @@ namespace App\Http\Controllers\Api\V1;
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
|
||||
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
|
||||
use App\Http\Resources\V1\TimeEntry\TimeEntryResource;
|
||||
use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\TimeEntryAggregationService;
|
||||
use App\Service\TimeEntryFilter;
|
||||
@@ -43,6 +47,8 @@ class TimeEntryController extends Controller
|
||||
* If you only need time entries for a specific user, you can filter by `user_id`.
|
||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
||||
*
|
||||
* @return TimeEntryCollection<TimeEntryResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getTimeEntries
|
||||
@@ -73,11 +79,14 @@ class TimeEntryController extends Controller
|
||||
$filter->addClientIdsFilter($request->input('client_ids'));
|
||||
$filter->addBillableFilter($request->input('billable'));
|
||||
|
||||
$limit = $request->has('limit') ? (int) $request->input('limit', 100) : 100;
|
||||
$totalCount = $timeEntriesQuery->count();
|
||||
|
||||
$limit = $request->getLimit();
|
||||
if ($limit > 1000) {
|
||||
$limit = 1000;
|
||||
}
|
||||
$timeEntriesQuery->limit($limit);
|
||||
$timeEntriesQuery->skip($request->getOffset());
|
||||
|
||||
$timeEntries = $timeEntriesQuery->get();
|
||||
|
||||
@@ -111,7 +120,12 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return new TimeEntryCollection($timeEntries);
|
||||
return (new TimeEntryCollection($timeEntries))
|
||||
->additional([
|
||||
'meta' => [
|
||||
'total' => $totalCount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,7 +229,16 @@ class TimeEntryController extends Controller
|
||||
throw new TimeEntryStillRunningApiException;
|
||||
}
|
||||
|
||||
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
|
||||
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
||||
$client = $project?->client;
|
||||
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
|
||||
|
||||
if ($project !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->fill($request->validated());
|
||||
@@ -250,16 +273,38 @@ class TimeEntryController extends Controller
|
||||
throw new TimeEntryCanNotBeRestartedApiException;
|
||||
}
|
||||
|
||||
$oldProject = $timeEntry->project;
|
||||
$oldTask = $timeEntry->task;
|
||||
|
||||
$project = null;
|
||||
if ($request->has('project_id')) {
|
||||
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
|
||||
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
||||
$client = $project?->client;
|
||||
$timeEntry->client()->associate($client);
|
||||
}
|
||||
$task = null;
|
||||
if ($request->has('task_id')) {
|
||||
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
|
||||
}
|
||||
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
|
||||
if ($oldProject !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($oldProject);
|
||||
}
|
||||
if ($oldTask !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($oldTask);
|
||||
}
|
||||
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
return new TimeEntryResource($timeEntry);
|
||||
}
|
||||
|
||||
@@ -279,6 +324,10 @@ class TimeEntryController extends Controller
|
||||
|
||||
$timeEntries = TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with([
|
||||
'project',
|
||||
'task',
|
||||
])
|
||||
->whereIn('id', $ids)
|
||||
->get();
|
||||
|
||||
@@ -288,13 +337,20 @@ class TimeEntryController extends Controller
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$project = null;
|
||||
$client = null;
|
||||
$overwriteClient = false;
|
||||
if ($request->has('changes.project_id')) {
|
||||
$client = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id'))->client : null;
|
||||
$project = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id')) : null;
|
||||
$client = $project?->client;
|
||||
$overwriteClient = true;
|
||||
}
|
||||
|
||||
$task = null;
|
||||
if ($request->has('changes.task_id')) {
|
||||
$task = $request->input('changes.task_id') !== null ? Task::findOrFail((string) $request->input('changes.task_id')) : null;
|
||||
}
|
||||
|
||||
$success = new Collection;
|
||||
$error = new Collection;
|
||||
|
||||
@@ -313,12 +369,32 @@ class TimeEntryController extends Controller
|
||||
continue;
|
||||
|
||||
}
|
||||
$oldProject = $timeEntry->project;
|
||||
$oldTask = $timeEntry->task;
|
||||
|
||||
$timeEntry->fill($changes);
|
||||
// If project is changed, but task is not, we remove the old task from the time entry
|
||||
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
||||
$timeEntry->task()->disassociate();
|
||||
}
|
||||
if ($overwriteClient) {
|
||||
$timeEntry->client()->associate($client);
|
||||
}
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
if ($oldTask !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($oldTask);
|
||||
}
|
||||
if ($oldProject !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($oldProject);
|
||||
}
|
||||
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
$success->push($id);
|
||||
}
|
||||
|
||||
@@ -343,9 +419,81 @@ class TimeEntryController extends Controller
|
||||
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
||||
}
|
||||
|
||||
$project = $timeEntry->project;
|
||||
$task = $timeEntry->task;
|
||||
|
||||
$timeEntry->delete();
|
||||
|
||||
if ($project !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple time entries
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId deleteTimeEntries
|
||||
*/
|
||||
public function destroyMultiple(Organization $organization, TimeEntryDestroyMultipleRequest $request): JsonResponse
|
||||
{
|
||||
$this->checkAnyPermission($organization, ['time-entries:delete:all', 'time-entries:delete:own']);
|
||||
$canDeleteAll = $this->hasPermission($organization, 'time-entries:delete:all');
|
||||
|
||||
$ids = $request->validated('ids');
|
||||
$timeEntries = TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with([
|
||||
'project',
|
||||
'task',
|
||||
])
|
||||
->whereIn('id', $ids)
|
||||
->get();
|
||||
|
||||
$success = new Collection;
|
||||
$error = new Collection;
|
||||
|
||||
foreach ($ids as $id) {
|
||||
/** @var TimeEntry|null $timeEntry */
|
||||
$timeEntry = $timeEntries->firstWhere('id', $id);
|
||||
if ($timeEntry === null) {
|
||||
// Note: ID wrong or time entry in different organization
|
||||
$error->push($id);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
|
||||
$error->push($id);
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
$project = $timeEntry->project;
|
||||
$task = $timeEntry->task;
|
||||
|
||||
$timeEntry->delete();
|
||||
|
||||
if ($project !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
$success->push($id);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => $success->toArray(),
|
||||
'error' => $error->toArray(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class ClientIndexRequest extends FormRequest
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
'archived' => [
|
||||
'string',
|
||||
|
||||
@@ -29,10 +29,10 @@ class ClientStoreRequest extends FormRequest
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->withCustomTranslation('validation.client_name_already_exists'),
|
||||
})->withCustomTranslation('validation.client_name_already_exists'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ class ClientUpdateRequest extends FormRequest
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'),
|
||||
})->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'),
|
||||
],
|
||||
'is_archived' => [
|
||||
'boolean',
|
||||
|
||||
@@ -29,10 +29,10 @@ class InvitationStoreRequest extends FormRequest
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
|
||||
/** @var Builder<OrganizationInvitation> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->withCustomTranslation('validation.invitation_already_exists'),
|
||||
})->withCustomTranslation('validation.invitation_already_exists'),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
|
||||
@@ -31,6 +31,7 @@ class MemberUpdateRequest extends FormRequest
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ class OrganizationUpdateRequest extends FormRequest
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
'employees_can_see_billable_rates' => [
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class ProjectIndexRequest extends FormRequest
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
'archived' => [
|
||||
'string',
|
||||
|
||||
@@ -32,10 +32,10 @@ class ProjectStoreRequest extends FormRequest
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->withCustomTranslation('validation.project_name_already_exists'),
|
||||
})->withCustomTranslation('validation.project_name_already_exists'),
|
||||
],
|
||||
'color' => [
|
||||
'required',
|
||||
@@ -51,13 +51,22 @@ class ProjectStoreRequest extends FormRequest
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
// ID of the client
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Estimated time in seconds
|
||||
'estimated_time' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -68,4 +77,11 @@ class ProjectStoreRequest extends FormRequest
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||
}
|
||||
|
||||
public function getEstimatedTime(): ?int
|
||||
{
|
||||
$input = $this->input('estimated_time');
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
|
||||
})->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
|
||||
],
|
||||
'color' => [
|
||||
'required',
|
||||
@@ -52,15 +52,23 @@ class ProjectUpdateRequest extends FormRequest
|
||||
],
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
// Estimated time in seconds
|
||||
'estimated_time' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -78,4 +86,11 @@ class ProjectUpdateRequest extends FormRequest
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||
}
|
||||
|
||||
public function getEstimatedTime(): ?int
|
||||
{
|
||||
$input = $this->input('estimated_time');
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,16 +26,16 @@ class ProjectMemberStoreRequest extends FormRequest
|
||||
return [
|
||||
'member_id' => [
|
||||
'required',
|
||||
'uuid',
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class ProjectMemberUpdateRequest extends FormRequest
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ class TagStoreRequest extends FormRequest
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->withCustomTranslation('validation.tag_name_already_exists'),
|
||||
})->withCustomTranslation('validation.tag_name_already_exists'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ class TagUpdateRequest extends FormRequest
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'),
|
||||
})->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ class TaskIndexRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'project_id' => [
|
||||
'uuid',
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||
|
||||
@@ -37,7 +36,7 @@ class TaskIndexRequest extends FormRequest
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
'done' => [
|
||||
'string',
|
||||
|
||||
@@ -31,18 +31,32 @@ class TaskStoreRequest extends FormRequest
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->where('project_id', '=', $this->input('project_id'));
|
||||
}))->withCustomTranslation('validation.task_name_already_exists'),
|
||||
})->withCustomTranslation('validation.task_name_already_exists'),
|
||||
],
|
||||
'project_id' => [
|
||||
'required',
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Estimated time in seconds
|
||||
'estimated_time' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getEstimatedTime(): ?int
|
||||
{
|
||||
$input = $this->input('estimated_time');
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,14 +30,21 @@ class TaskUpdateRequest extends FormRequest
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->where('project_id', '=', $this->task->project_id);
|
||||
}))->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'),
|
||||
})->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'),
|
||||
],
|
||||
'is_done' => [
|
||||
'boolean',
|
||||
],
|
||||
// Estimated time in seconds
|
||||
'estimated_time' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -47,4 +54,11 @@ class TaskUpdateRequest extends FormRequest
|
||||
|
||||
return $this->boolean('is_done');
|
||||
}
|
||||
|
||||
public function getEstimatedTime(): ?int
|
||||
{
|
||||
$input = $this->input('estimated_time');
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,10 @@ class TimeEntryAggregateRequest extends FormRequest
|
||||
// Filter by member ID
|
||||
'member_id' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
|
||||
'member_ids' => [
|
||||
@@ -58,21 +57,19 @@ class TimeEntryAggregateRequest extends FormRequest
|
||||
],
|
||||
'member_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
|
||||
// Filter by user ID
|
||||
'user_id' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(User::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by project IDs, project IDs are OR combined
|
||||
'project_ids' => [
|
||||
@@ -81,11 +78,10 @@ class TimeEntryAggregateRequest extends FormRequest
|
||||
],
|
||||
'project_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by client IDs, client IDs are OR combined
|
||||
'client_ids' => [
|
||||
@@ -94,11 +90,10 @@ class TimeEntryAggregateRequest extends FormRequest
|
||||
],
|
||||
'client_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by tag IDs, tag IDs are AND combined
|
||||
'tag_ids' => [
|
||||
@@ -107,11 +102,10 @@ class TimeEntryAggregateRequest extends FormRequest
|
||||
],
|
||||
'tag_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
@@ -120,10 +114,9 @@ class TimeEntryAggregateRequest extends FormRequest
|
||||
],
|
||||
'task_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||
'start' => [
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class TimeEntryDestroyMultipleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ids' => [
|
||||
'required',
|
||||
'array',
|
||||
],
|
||||
'ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
@@ -30,11 +31,10 @@ class TimeEntryIndexRequest extends FormRequest
|
||||
// Filter by member ID
|
||||
'member_id' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
|
||||
'member_ids' => [
|
||||
@@ -43,11 +43,22 @@ class TimeEntryIndexRequest extends FormRequest
|
||||
],
|
||||
'member_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by client IDs, client IDs are OR combined
|
||||
'client_ids' => [
|
||||
'array',
|
||||
'min:1',
|
||||
],
|
||||
'client_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by project IDs, project IDs are OR combined
|
||||
'project_ids' => [
|
||||
@@ -56,11 +67,10 @@ class TimeEntryIndexRequest extends FormRequest
|
||||
],
|
||||
'project_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by tag IDs, tag IDs are AND combined
|
||||
'tag_ids' => [
|
||||
@@ -69,11 +79,10 @@ class TimeEntryIndexRequest extends FormRequest
|
||||
],
|
||||
'tag_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
@@ -82,11 +91,10 @@ class TimeEntryIndexRequest extends FormRequest
|
||||
],
|
||||
'task_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||
'start' => [
|
||||
@@ -117,6 +125,12 @@ class TimeEntryIndexRequest extends FormRequest
|
||||
'min:1',
|
||||
'max:500',
|
||||
],
|
||||
// Skip the first n time entries (default: 0)
|
||||
'offset' => [
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
// Filter makes sure that only time entries of a whole date are returned
|
||||
'only_full_dates' => [
|
||||
'string',
|
||||
@@ -129,4 +143,14 @@ class TimeEntryIndexRequest extends FormRequest
|
||||
{
|
||||
return $this->input('only_full_dates', 'false') === 'true';
|
||||
}
|
||||
|
||||
public function getLimit(): int
|
||||
{
|
||||
return $this->has('limit') ? (int) $this->validated('limit', 100) : 100;
|
||||
}
|
||||
|
||||
public function getOffset(): int
|
||||
{
|
||||
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,36 +31,33 @@ class TimeEntryStoreRequest extends FormRequest
|
||||
'member_id' => [
|
||||
'required',
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
'project_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'uuid',
|
||||
'required_with:task_id',
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
'task_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||
})->uuid(),
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||
->where('project_id', $this->input('project_id'));
|
||||
}))->withMessage(__('validation.task_belongs_to_project')),
|
||||
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
|
||||
],
|
||||
// Start of time entry (ISO 8601 format, UTC timezone)
|
||||
'start' => [
|
||||
@@ -90,12 +87,10 @@ class TimeEntryStoreRequest extends FormRequest
|
||||
'array',
|
||||
],
|
||||
'tags.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,37 +42,34 @@ class TimeEntryUpdateMultipleRequest extends FormRequest
|
||||
// ID of the organization member that the time entry should belong to
|
||||
'changes.member_id' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the project that the time entry should belong to
|
||||
'changes.project_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'uuid',
|
||||
'required_with:task_id',
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
'changes.task_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||
})->uuid(),
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||
->where('project_id', $this->input('changes.project_id'));
|
||||
}))->withMessage(__('validation.task_belongs_to_project')),
|
||||
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
|
||||
],
|
||||
// Whether time entry is billable
|
||||
'changes.billable' => [
|
||||
@@ -91,11 +88,10 @@ class TimeEntryUpdateMultipleRequest extends FormRequest
|
||||
],
|
||||
'changes.tags.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,37 +30,34 @@ class TimeEntryUpdateRequest extends FormRequest
|
||||
// ID of the organization member that the time entry should belong to
|
||||
'member_id' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the project that the time entry should belong to
|
||||
'project_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'uuid',
|
||||
'required_with:task_id',
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
'task_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||
})->uuid(),
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||
->where('project_id', $this->input('project_id'));
|
||||
}))->withMessage(__('validation.task_belongs_to_project')),
|
||||
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
|
||||
],
|
||||
// Start of time entry (ISO 8601 format, UTC timezone)
|
||||
'start' => [
|
||||
@@ -89,11 +86,10 @@ class TimeEntryUpdateRequest extends FormRequest
|
||||
],
|
||||
'tags.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
})->uuid(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,20 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
class OrganizationResource extends BaseResource
|
||||
{
|
||||
private bool $showBillableRate;
|
||||
|
||||
/**
|
||||
* Create a new resource instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Organization $resource, bool $showBillableRate)
|
||||
{
|
||||
parent::__construct($resource);
|
||||
|
||||
$this->showBillableRate = $showBillableRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
@@ -28,7 +42,9 @@ class OrganizationResource extends BaseResource
|
||||
/** @var bool $color Personal organizations automatically created after registration */
|
||||
'is_personal' => $this->resource->personal_team,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,39 @@ declare(strict_types=1);
|
||||
namespace App\Http\Resources\V1\Project;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
private bool $showBillableRates;
|
||||
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
* @param LengthAwarePaginator<Project> $resource
|
||||
*/
|
||||
public $collects = ProjectResource::class;
|
||||
public function __construct($resource, bool $showBillableRates)
|
||||
{
|
||||
parent::__construct($resource);
|
||||
$this->showBillableRates = $showBillableRates;
|
||||
}
|
||||
|
||||
protected function collects(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the resource collection into an array.
|
||||
*
|
||||
* @return array<array<string, string|bool|int|null>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return $this->collection->map(function (Project $project) use ($request): array {
|
||||
return (new ProjectResource($project, $this->showBillableRates))
|
||||
->toArray($request);
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
class ProjectResource extends BaseResource
|
||||
{
|
||||
private bool $showBillableRate;
|
||||
|
||||
public function __construct(Project $resource, bool $showBillableRate)
|
||||
{
|
||||
parent::__construct($resource);
|
||||
|
||||
$this->showBillableRate = $showBillableRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
@@ -32,9 +41,13 @@ class ProjectResource extends BaseResource
|
||||
/** @var bool $is_archived Whether the client is archived */
|
||||
'is_archived' => $this->resource->is_archived,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||
/** @var bool $is_billable Project time entries billable default */
|
||||
'is_billable' => $this->resource->is_billable,
|
||||
/** @var int|null $estimated_time Estimated time in seconds */
|
||||
'estimated_time' => $this->resource->estimated_time,
|
||||
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
'spent_time' => $this->resource->spent_time,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ class TaskResource extends BaseResource
|
||||
'is_done' => $this->resource->is_done,
|
||||
/** @var string $project_id ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
/** @var int|null $estimated_time Estimated time in seconds */
|
||||
'estimated_time' => $this->resource->estimated_time,
|
||||
/** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
'spent_time' => $this->resource->spent_time,
|
||||
/** @var string $created_at When the tag was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string $updated_at When the tag was last updated */
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\TimeEntry;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class TimeEntryCollection extends ResourceCollection
|
||||
class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
44
app/Jobs/RecalculateSpentTimeForProject.php
Normal file
44
app/Jobs/RecalculateSpentTimeForProject.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Project;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RecalculateSpentTimeForProject implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public Project $project;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$this->project->setComputedAttributeValue('spent_time');
|
||||
if ($this->project->isDirty()) {
|
||||
$this->project->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
44
app/Jobs/RecalculateSpentTimeForTask.php
Normal file
44
app/Jobs/RecalculateSpentTimeForTask.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Task;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RecalculateSpentTimeForTask implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public Task $task;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(Task $task)
|
||||
{
|
||||
$this->task = $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$this->task->setComputedAttributeValue('spent_time');
|
||||
if ($this->task->isDirty()) {
|
||||
$this->task->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $currency
|
||||
* @property int|null $billable_rate
|
||||
* @property string $user_id
|
||||
* @property bool $employees_can_see_billable_rates
|
||||
* @property User $owner
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
@@ -58,6 +59,7 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
'name' => 'string',
|
||||
'personal_team' => 'boolean',
|
||||
'currency' => 'string',
|
||||
'employees_can_see_billable_rates' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property bool $is_public
|
||||
* @property bool $is_billable
|
||||
* @property-read bool $is_archived
|
||||
* @property int|null $estimated_time
|
||||
* @property int $spent_time
|
||||
* @property Carbon|null $archived_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
@@ -40,6 +44,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
*/
|
||||
class Project extends Model implements AuditableContract
|
||||
{
|
||||
use ComputedAttributes;
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<ProjectFactory> */
|
||||
@@ -56,6 +61,8 @@ class Project extends Model implements AuditableContract
|
||||
'name' => 'string',
|
||||
'color' => 'string',
|
||||
'archived_at' => 'datetime',
|
||||
'estimated_time' => 'integer',
|
||||
'spent_time' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -67,6 +74,68 @@ class Project extends Model implements AuditableContract
|
||||
'is_billable' => false,
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are computed. (f.e. for performance reasons)
|
||||
* These attributes can be regenerated at any time.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $computed = [
|
||||
'spent_time',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes to exclude from the Audit.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $auditExclude = [
|
||||
'spent_time',
|
||||
];
|
||||
|
||||
public function getSpentTimeComputed(): ?int
|
||||
{
|
||||
if ($this->hasAttribute('spent_time_computed')) {
|
||||
return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];
|
||||
} else {
|
||||
/** @var object{ spent_time: string } $result */
|
||||
$result = $this->timeEntries()
|
||||
->whereNotNull('end')
|
||||
->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time')
|
||||
->first();
|
||||
|
||||
return (int) $result->spent_time;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
|
||||
*
|
||||
* @param Builder<Project> $builder
|
||||
* @param array<string> $attributes Attributes that will be generated.
|
||||
* @return Builder<Project>
|
||||
*/
|
||||
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
|
||||
{
|
||||
if (in_array('spent_time', $attributes, true)) {
|
||||
$builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum');
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
|
||||
*
|
||||
* @param Builder<Project> $builder
|
||||
* @param array<string> $attributes Attributes that will be validated.
|
||||
* @return Builder<Project>
|
||||
*/
|
||||
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
|
||||
{
|
||||
return $this->scopeComputedAttributesGenerate($builder, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Project>
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $project_id
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $done_at
|
||||
* @property int|null $estimated_time
|
||||
* @property int $spent_time
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Project $project
|
||||
@@ -34,6 +38,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
*/
|
||||
class Task extends Model implements AuditableContract
|
||||
{
|
||||
use ComputedAttributes;
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<TaskFactory> */
|
||||
@@ -48,9 +53,72 @@ class Task extends Model implements AuditableContract
|
||||
*/
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'estimated_time' => 'integer',
|
||||
'done_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are computed. (f.e. for performance reasons)
|
||||
* These attributes can be regenerated at any time.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $computed = [
|
||||
'spent_time',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes to exclude from the Audit.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $auditExclude = [
|
||||
'spent_time',
|
||||
];
|
||||
|
||||
public function getSpentTimeComputed(): ?int
|
||||
{
|
||||
if ($this->hasAttribute('spent_time_computed')) {
|
||||
return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];
|
||||
} else {
|
||||
/** @var object{ spent_time: string } $result */
|
||||
$result = $this->timeEntries()
|
||||
->whereNotNull('end')
|
||||
->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time')
|
||||
->first();
|
||||
|
||||
return (int) $result->spent_time;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
|
||||
*
|
||||
* @param Builder<Task> $builder
|
||||
* @param array<string> $attributes Attributes that will be generated.
|
||||
* @return Builder<Task>
|
||||
*/
|
||||
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
|
||||
{
|
||||
if (in_array('spent_time', $attributes, true)) {
|
||||
$builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum');
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
|
||||
*
|
||||
* @param Builder<Task> $builder
|
||||
* @param array<string> $attributes Attributes that will be validated.
|
||||
* @return Builder<Task>
|
||||
*/
|
||||
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
|
||||
{
|
||||
return $this->scopeComputedAttributesGenerate($builder, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, Task>
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
@@ -79,6 +80,16 @@ class TimeEntry extends Model implements AuditableContract
|
||||
*/
|
||||
protected array $computed = [
|
||||
'billable_rate',
|
||||
'client_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes to exclude from the Audit.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $auditExclude = [
|
||||
'billable_rate',
|
||||
];
|
||||
|
||||
public function getBillableRateComputed(): ?int
|
||||
@@ -86,6 +97,44 @@ class TimeEntry extends Model implements AuditableContract
|
||||
return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
|
||||
}
|
||||
|
||||
public function getClientIdComputed(): ?string
|
||||
{
|
||||
return $this->project_id === null ? null : $this->project->client_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
|
||||
*
|
||||
* @param Builder<TimeEntry> $builder
|
||||
* @param array<string> $attributes Attributes that will be generated.
|
||||
* @return Builder<TimeEntry>
|
||||
*/
|
||||
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
|
||||
{
|
||||
if (in_array('client_id', $attributes, true)) {
|
||||
$builder->with([
|
||||
'project' => function (Relation $builder): void {
|
||||
/** @var Builder<Project> $builder */
|
||||
$builder->select('id', 'client_id');
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
|
||||
*
|
||||
* @param Builder<TimeEntry> $builder
|
||||
* @param array<string> $attributes Attributes that will be validated.
|
||||
* @return Builder<TimeEntry>
|
||||
*/
|
||||
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
|
||||
{
|
||||
return $this->scopeComputedAttributesGenerate($builder, $attributes);
|
||||
}
|
||||
|
||||
public function getDuration(): ?CarbonInterval
|
||||
{
|
||||
return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);
|
||||
|
||||
@@ -28,7 +28,6 @@ use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -81,19 +80,19 @@ class AppServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
// Scramble
|
||||
Scramble::extendOpenApi(function (OpenApi $openApi) {
|
||||
Scramble::extendOpenApi(function (OpenApi $openApi): void {
|
||||
$openApi->secure(
|
||||
SecurityScheme::oauth2()
|
||||
->flow('authorizationCode', function (OAuthFlow $flow) {
|
||||
->flow('authorizationCode', function (OAuthFlow $flow): void {
|
||||
$flow
|
||||
->authorizationUrl('https://solidtime.test/oauth/authorize');
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (config('app.force_https', false) || App::isProduction()) {
|
||||
if (config('app.force_https', false)) {
|
||||
URL::forceScheme('https');
|
||||
request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off');
|
||||
request()->server->set('HTTPS', 'on');
|
||||
}
|
||||
|
||||
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Widgets\ActiveUserOverview;
|
||||
use App\Filament\Widgets\ServerOverview;
|
||||
use App\Filament\Widgets\TimeEntriesCreated;
|
||||
use App\Filament\Widgets\TimeEntriesImported;
|
||||
use App\Filament\Widgets\UserRegistrations;
|
||||
@@ -44,11 +45,13 @@ class AdminPanelProvider extends PanelProvider
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||
->widgets([
|
||||
ServerOverview::class,
|
||||
ActiveUserOverview::class,
|
||||
UserRegistrations::class,
|
||||
TimeEntriesCreated::class,
|
||||
TimeEntriesImported::class,
|
||||
])
|
||||
->viteTheme('resources/css/filament/admin/theme.css')
|
||||
->plugins([
|
||||
EnvironmentIndicatorPlugin::make()
|
||||
->color(fn () => match (App::environment()) {
|
||||
|
||||
@@ -37,9 +37,9 @@ class RouteServiceProvider extends ServiceProvider
|
||||
: Limit::perMinute(60)->by($request->ip());
|
||||
});
|
||||
|
||||
$this->routes(function () {
|
||||
$this->routes(function (): void {
|
||||
Route::middleware('health-check')
|
||||
->group(function () {
|
||||
->group(function (): void {
|
||||
Route::get('health-check/up', [HealthCheckController::class, 'up']);
|
||||
Route::get('health-check/debug', [HealthCheckController::class, 'debug']);
|
||||
});
|
||||
|
||||
93
app/Service/ApiService.php
Normal file
93
app/Service/ApiService.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\Audit;
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Log;
|
||||
|
||||
class ApiService
|
||||
{
|
||||
private const string API_URL = 'https://app.solidtime.io/api/v1';
|
||||
|
||||
public function checkForUpdate(): ?string
|
||||
{
|
||||
try {
|
||||
$response = Http::asJson()
|
||||
->timeout(3)
|
||||
->connectTimeout(2)
|
||||
->post(self::API_URL.'/ping/version', [
|
||||
'version' => config('app.version'),
|
||||
'build' => config('app.build'),
|
||||
'url' => config('app.url'),
|
||||
]);
|
||||
|
||||
if ($response->status() === 200 && isset($response->json()['version']) && is_string($response->json()['version'])) {
|
||||
return $response->json()['version'];
|
||||
} else {
|
||||
Log::warning('Failed to check for update', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to check for update', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function telemetry(): bool
|
||||
{
|
||||
try {
|
||||
$response = Http::asJson()
|
||||
->timeout(3)
|
||||
->connectTimeout(2)
|
||||
->post(self::API_URL.'/ping/telemetry', [
|
||||
'version' => config('app.version'),
|
||||
'build' => config('app.build'),
|
||||
'url' => config('app.url'),
|
||||
// telemetry data
|
||||
'user_count' => User::count(),
|
||||
'organization_count' => Organization::count(),
|
||||
'audit_count' => Audit::count(),
|
||||
'project_count' => Project::count(),
|
||||
'project_member_count' => ProjectMember::count(),
|
||||
'client_count' => Client::count(),
|
||||
'task_count' => Task::count(),
|
||||
'time_entry_count' => TimeEntry::count(),
|
||||
]);
|
||||
|
||||
if ($response->status() === 200) {
|
||||
return true;
|
||||
} else {
|
||||
Log::warning('Failed send telemetry data', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::warning('Failed send telemetry data', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,9 @@ class BillableRateService
|
||||
->where('billable', '=', true)
|
||||
->where('organization_id', '=', $project->organization_id)
|
||||
->whereBelongsTo($project, 'project')
|
||||
->whereDoesntHave('member', function (Builder $query) use ($project) {
|
||||
->whereDoesntHave('member', function (Builder $query) use ($project): void {
|
||||
/** @var Builder<Member> $query */
|
||||
$query->whereHas('projectMembers', function (Builder $query) use ($project) {
|
||||
$query->whereHas('projectMembers', function (Builder $query) use ($project): void {
|
||||
/** @var Builder<ProjectMember> $query */
|
||||
$query->whereBelongsTo($project, 'project')
|
||||
->whereNotNull('billable_rate');
|
||||
@@ -62,7 +62,7 @@ class BillableRateService
|
||||
TimeEntry::query()
|
||||
->where('billable', '=', true)
|
||||
->where('organization_id', '=', $organization->getKey())
|
||||
->whereDoesntHave('member', function (Builder $builder) {
|
||||
->whereDoesntHave('member', function (Builder $builder): void {
|
||||
/** @var Builder<Member> $builder */
|
||||
$builder->whereNotNull('billable_rate');
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ class DeletionService
|
||||
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
|
||||
{
|
||||
if ($inTransaction) {
|
||||
DB::transaction(function () use ($organization) {
|
||||
DB::transaction(function () use ($organization): void {
|
||||
$this->deleteOrganization($organization, false);
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ class DeletionService
|
||||
public function deleteUser(User $user, bool $inTransaction = true): void
|
||||
{
|
||||
if ($inTransaction) {
|
||||
DB::transaction(function () use ($user) {
|
||||
DB::transaction(function () use ($user): void {
|
||||
$this->deleteUser($user, false);
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ class ExportService
|
||||
// Organizations
|
||||
try {
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+');
|
||||
$writer->setDelimiter(',');
|
||||
$writer->setEnclosure('"');
|
||||
$writer->setEscape('');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
@@ -66,6 +69,9 @@ class ExportService
|
||||
|
||||
// Organization invitations
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+');
|
||||
$writer->setDelimiter(',');
|
||||
$writer->setEnclosure('"');
|
||||
$writer->setEscape('');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'email',
|
||||
@@ -91,6 +97,9 @@ class ExportService
|
||||
|
||||
// Time entries
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+');
|
||||
$writer->setDelimiter(',');
|
||||
$writer->setEnclosure('"');
|
||||
$writer->setEscape('');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'description',
|
||||
@@ -139,6 +148,9 @@ class ExportService
|
||||
|
||||
// Clients
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+');
|
||||
$writer->setDelimiter(',');
|
||||
$writer->setEnclosure('"');
|
||||
$writer->setEscape('');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
@@ -164,6 +176,9 @@ class ExportService
|
||||
|
||||
// Projects
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+');
|
||||
$writer->setDelimiter(',');
|
||||
$writer->setEnclosure('"');
|
||||
$writer->setEscape('');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
@@ -199,6 +214,9 @@ class ExportService
|
||||
|
||||
// Project members
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+');
|
||||
$writer->setDelimiter(',');
|
||||
$writer->setEnclosure('"');
|
||||
$writer->setEscape('');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'billable_rate',
|
||||
@@ -226,6 +244,9 @@ class ExportService
|
||||
|
||||
// Members
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+');
|
||||
$writer->setDelimiter(',');
|
||||
$writer->setEnclosure('"');
|
||||
$writer->setEscape('');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'user_id',
|
||||
@@ -260,6 +281,9 @@ class ExportService
|
||||
|
||||
// Tasks
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+');
|
||||
$writer->setDelimiter(',');
|
||||
$writer->setEnclosure('"');
|
||||
$writer->setEscape('');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
@@ -287,6 +311,9 @@ class ExportService
|
||||
|
||||
// Tags
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+');
|
||||
$writer->setDelimiter(',');
|
||||
$writer->setEnclosure('"');
|
||||
$writer->setEscape('');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
|
||||
@@ -31,7 +31,7 @@ class ImportService
|
||||
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
||||
|
||||
if ($lock->get()) {
|
||||
DB::transaction(function () use (&$importer, &$data, &$timezone) {
|
||||
DB::transaction(function () use (&$importer, &$data, &$timezone): void {
|
||||
$importer->importData($data, $timezone);
|
||||
});
|
||||
$lock->release();
|
||||
|
||||
@@ -47,6 +47,8 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$reader->setEnclosure('"');
|
||||
$reader->setEscape('');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
|
||||
@@ -112,8 +112,9 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'max:2147483647',
|
||||
],
|
||||
], beforeSave: function (Project $project) {
|
||||
], beforeSave: function (Project $project): void {
|
||||
if ($project->billable_rate === 0) {
|
||||
$project->billable_rate = null;
|
||||
}
|
||||
@@ -125,8 +126,9 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'max:2147483647',
|
||||
],
|
||||
], beforeSave: function (ProjectMember $projectMember) {
|
||||
], beforeSave: function (ProjectMember $projectMember): void {
|
||||
if ($projectMember->billable_rate === 0) {
|
||||
$projectMember->billable_rate = null;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv'));
|
||||
$clientsReader->setHeaderOffset(0);
|
||||
$clientsReader->setDelimiter(',');
|
||||
$clientsReader->setEnclosure('"');
|
||||
$clientsReader->setEscape('');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('members.csv'))) {
|
||||
throw new ImportException('File "members.csv" missing in ZIP');
|
||||
@@ -67,6 +69,8 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv'));
|
||||
$membersReader->setHeaderOffset(0);
|
||||
$membersReader->setDelimiter(',');
|
||||
$membersReader->setEnclosure('"');
|
||||
$membersReader->setEscape('');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) {
|
||||
throw new ImportException('File "organization_invitations.csv" missing in ZIP');
|
||||
@@ -74,6 +78,8 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv'));
|
||||
$organizationInvitationsReader->setHeaderOffset(0);
|
||||
$organizationInvitationsReader->setDelimiter(',');
|
||||
$organizationInvitationsReader->setEnclosure('"');
|
||||
$organizationInvitationsReader->setEscape('');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('project_members.csv'))) {
|
||||
throw new ImportException('File "project_members.csv" missing in ZIP');
|
||||
@@ -81,6 +87,8 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv'));
|
||||
$projectMembersReader->setHeaderOffset(0);
|
||||
$projectMembersReader->setDelimiter(',');
|
||||
$projectMembersReader->setEnclosure('"');
|
||||
$projectMembersReader->setEscape('');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('projects.csv'))) {
|
||||
throw new ImportException('File "projects.csv" missing in ZIP');
|
||||
@@ -88,6 +96,8 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv'));
|
||||
$projectsReader->setHeaderOffset(0);
|
||||
$projectsReader->setDelimiter(',');
|
||||
$projectsReader->setEnclosure('"');
|
||||
$projectsReader->setEscape('');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('tags.csv'))) {
|
||||
throw new ImportException('File "tags.csv" missing in ZIP');
|
||||
@@ -95,6 +105,8 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv'));
|
||||
$tagsReader->setHeaderOffset(0);
|
||||
$tagsReader->setDelimiter(',');
|
||||
$tagsReader->setEnclosure('"');
|
||||
$tagsReader->setEscape('');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('tasks.csv'))) {
|
||||
throw new ImportException('File "tasks.csv" missing in ZIP');
|
||||
@@ -102,6 +114,8 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv'));
|
||||
$tasksReader->setHeaderOffset(0);
|
||||
$tasksReader->setDelimiter(',');
|
||||
$tasksReader->setEnclosure('"');
|
||||
$tasksReader->setEscape('');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('time_entries.csv'))) {
|
||||
throw new ImportException('File "time_entries.csv" missing in ZIP');
|
||||
@@ -109,6 +123,8 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv'));
|
||||
$timeEntriesReader->setHeaderOffset(0);
|
||||
$timeEntriesReader->setDelimiter(',');
|
||||
$timeEntriesReader->setEnclosure('"');
|
||||
$timeEntriesReader->setEscape('');
|
||||
|
||||
foreach ($clientsReader as $client) {
|
||||
$this->clientImportHelper->getKey([
|
||||
|
||||
@@ -47,6 +47,8 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$reader->setEnclosure('"');
|
||||
$reader->setEscape('');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"name": "solidtime-io/solidtime",
|
||||
"type": "project",
|
||||
"description": "An open-source time-tracking app",
|
||||
"version": "0.0.1",
|
||||
"keywords": [],
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"require": {
|
||||
@@ -29,7 +28,7 @@
|
||||
"spatie/temporary-directory": "^2.2",
|
||||
"stechstudio/filament-impersonate": "^3.8",
|
||||
"tightenco/ziggy": "^2.1.0",
|
||||
"tpetry/laravel-postgresql-enhanced": "^1.0.0",
|
||||
"tpetry/laravel-postgresql-enhanced": "^2.0.0",
|
||||
"wikimedia/composer-merge-plugin": "^2.1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
1184
composer.lock
generated
1184
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,11 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'solidtime'),
|
||||
'name' => 'solidtime',
|
||||
|
||||
'version' => env('APP_VERSION'),
|
||||
|
||||
'build' => env('APP_BUILD'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -6,5 +6,7 @@ return [
|
||||
|
||||
'tasks' => [
|
||||
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
|
||||
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
|
||||
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -85,7 +85,7 @@ class MemberFactory extends Factory
|
||||
|
||||
public function attachToOrganization(Organization $organization, array $pivot = []): static
|
||||
{
|
||||
return $this->afterCreating(function (User $user) use ($organization, $pivot) {
|
||||
return $this->afterCreating(function (User $user) use ($organization, $pivot): void {
|
||||
$user->organizations()->attach($organization, $pivot);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class OrganizationFactory extends Factory
|
||||
'billable_rate' => null,
|
||||
'user_id' => User::factory(),
|
||||
'personal_team' => true,
|
||||
'employees_can_see_billable_rates' => false,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Service\ColorService;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @extends Factory<Project>
|
||||
@@ -33,15 +34,34 @@ class ProjectFactory extends Factory
|
||||
'archived_at' => null,
|
||||
'client_id' => null,
|
||||
'organization_id' => Organization::factory(),
|
||||
'estimated_time' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function billable(): self
|
||||
public function withEstimatedTime(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
return [
|
||||
'estimated_time' => $this->faker->randomNumber(3),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function billable(?int $billableRate = null): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($billableRate): array {
|
||||
return [
|
||||
'is_billable' => true,
|
||||
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
||||
'billable_rate' => $billableRate === null ? $this->faker->numberBetween(50, 1000) * 100 : $billableRate,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function createdAt(Carbon $createdAt): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($createdAt): array {
|
||||
return [
|
||||
'created_at' => $createdAt,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class TaskFactory extends Factory
|
||||
'project_id' => Project::factory(),
|
||||
'organization_id' => Organization::factory(),
|
||||
'done_at' => null,
|
||||
'estimated_time' => null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -147,8 +147,8 @@ class TimeEntryFactory extends Factory
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($start, $durationInSeconds): array {
|
||||
return [
|
||||
'start' => $start->utc(),
|
||||
'end' => $start->copy()->addSeconds($durationInSeconds),
|
||||
'start' => $start->copy()->utc(),
|
||||
'end' => $start->copy()->utc()->addSeconds($durationInSeconds),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -157,7 +157,7 @@ class TimeEntryFactory extends Factory
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($start): array {
|
||||
return [
|
||||
'start' => $start->utc(),
|
||||
'start' => $start->copy()->utc(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ class UserFactory extends Factory
|
||||
|
||||
public function attachToOrganization(Organization $organization, array $pivot = []): static
|
||||
{
|
||||
return $this->afterCreating(function (User $user) use ($organization, $pivot) {
|
||||
return $this->afterCreating(function (User $user) use ($organization, $pivot): void {
|
||||
$user->organizations()->attach($organization, $pivot);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->string('email');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table): void {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
|
||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->text('two_factor_secret')
|
||||
->after('password')
|
||||
->nullable();
|
||||
@@ -36,7 +36,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropColumn(array_merge([
|
||||
'two_factor_secret',
|
||||
'two_factor_recovery_codes',
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_auth_codes', function (Blueprint $table) {
|
||||
Schema::create('oauth_auth_codes', function (Blueprint $table): void {
|
||||
$table->string('id', 100)->primary();
|
||||
$table->foreignUuid('user_id')->index();
|
||||
$table->uuid('client_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_access_tokens', function (Blueprint $table) {
|
||||
Schema::create('oauth_access_tokens', function (Blueprint $table): void {
|
||||
$table->string('id', 100)->primary();
|
||||
$table->foreignUuid('user_id')->nullable()->index();
|
||||
$table->uuid('client_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
|
||||
Schema::create('oauth_refresh_tokens', function (Blueprint $table): void {
|
||||
$table->string('id', 100)->primary();
|
||||
$table->string('access_token_id', 100)->index();
|
||||
$table->boolean('revoked');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_clients', function (Blueprint $table) {
|
||||
Schema::create('oauth_clients', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->nullable()->index();
|
||||
$table->string('name');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_personal_access_clients', function (Blueprint $table) {
|
||||
Schema::create('oauth_personal_access_clients', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->uuid('client_id');
|
||||
$table->timestamps();
|
||||
|
||||
@@ -26,7 +26,7 @@ return new class extends Migration
|
||||
}
|
||||
$schema = Schema::connection($this->getConnection());
|
||||
|
||||
$schema->create('telescope_entries', function (Blueprint $table) {
|
||||
$schema->create('telescope_entries', function (Blueprint $table): void {
|
||||
$table->bigIncrements('sequence');
|
||||
$table->uuid('uuid');
|
||||
$table->uuid('batch_id');
|
||||
@@ -43,7 +43,7 @@ return new class extends Migration
|
||||
$table->index(['type', 'should_display_on_index']);
|
||||
});
|
||||
|
||||
$schema->create('telescope_entries_tags', function (Blueprint $table) {
|
||||
$schema->create('telescope_entries_tags', function (Blueprint $table): void {
|
||||
$table->uuid('entry_uuid');
|
||||
$table->string('tag');
|
||||
|
||||
@@ -56,7 +56,7 @@ return new class extends Migration
|
||||
->onDelete('cascade');
|
||||
});
|
||||
|
||||
$schema->create('telescope_monitoring', function (Blueprint $table) {
|
||||
$schema->create('telescope_monitoring', function (Blueprint $table): void {
|
||||
$table->string('tag')->primary();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
Schema::create('failed_jobs', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->text('connection');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->morphs('tokenable');
|
||||
$table->string('name');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('organizations', function (Blueprint $table) {
|
||||
Schema::create('organizations', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->index();
|
||||
$table->string('name');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('organization_user', function (Blueprint $table) {
|
||||
Schema::create('organization_user', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('organization_id');
|
||||
$table->foreignUuid('user_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('organization_invitations', function (Blueprint $table) {
|
||||
Schema::create('organization_invitations', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('organization_id')
|
||||
->constrained()
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
Schema::create('sessions', function (Blueprint $table): void {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignUuid('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('clients', function (Blueprint $table) {
|
||||
Schema::create('clients', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name', 255);
|
||||
$table->uuid('organization_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('projects', function (Blueprint $table) {
|
||||
Schema::create('projects', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name', 255);
|
||||
$table->string('color', 16);
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tasks', function (Blueprint $table) {
|
||||
Schema::create('tasks', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name', 500);
|
||||
$table->uuid('project_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tags', function (Blueprint $table) {
|
||||
Schema::create('tags', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name', 255);
|
||||
$table->uuid('organization_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('time_entries', function (Blueprint $table) {
|
||||
Schema::create('time_entries', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('description', 500);
|
||||
$table->dateTime('start');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_members', function (Blueprint $table) {
|
||||
Schema::create('project_members', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->integer('billable_rate')->unsigned()->nullable();
|
||||
$table->uuid('project_id');
|
||||
|
||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
Schema::create('jobs', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
|
||||
@@ -13,13 +13,13 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
Schema::create('cache', function (Blueprint $table): void {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
Schema::create('cache_locks', function (Blueprint $table): void {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user