Compare commits

...

27 Commits

Author SHA1 Message Date
Constantin Graf
aedb9a016d Fixed modules service providers 2025-07-23 15:52:31 +02:00
Gregor Vostrak
43073b5be2 fix design inconsistency in timeentryaggregaterow 2025-07-18 16:38:09 +02:00
Gregor Vostrak
9589c9106d e2e: make sure reporting tests do not check the dropdown values when verifying table results 2025-07-17 18:41:48 +02:00
Gregor Vostrak
8a0d2235a8 fix flakyness in e2e tests for reporting 2025-07-17 18:38:21 +02:00
Gregor Vostrak
38f38790d5 change font to inter, scale down fonts, improve rounding/filter elements 2025-07-17 18:38:21 +02:00
Gregor Vostrak
e3cfc155b8 add rounding frontend to reports, and support for shared reports 2025-07-17 18:38:21 +02:00
Constantin Graf
4b726635b2 Add rounding feature 2025-07-17 18:38:21 +02:00
Constantin Graf
e1185af281 Fixed failing tests because of legacy currency codes 2025-07-17 18:16:25 +02:00
Constantin Graf
f9c0d64f82 Add email notifications for expiring api tokens 2025-07-17 18:16:25 +02:00
Constantin Graf
3d58f570bd Fixed Laravel passport migrations 2025-07-17 11:47:34 +02:00
Constantin Graf
400bc434b9 Updated docker image 2025-07-17 11:47:34 +02:00
Constantin Graf
2ab28001be Updated dependencies; Major update laravel passport 2025-07-17 11:47:34 +02:00
Gregor Vostrak
62d2f4bf4e fix broken light mode on oauth page #842 2025-07-15 15:52:55 +02:00
Gregor Vostrak
3d4b20f7c8 make sure time entry information remains visible on mobile views 2025-07-08 18:22:18 +02:00
Gregor Vostrak
155ed62fcc add clearable option to calendardateinput, fix format, add paid_date 2025-07-08 18:22:18 +02:00
Gregor Vostrak
5daa6f2a25 fix last 7 days statistic labels 2025-07-08 18:22:18 +02:00
Constantin Graf
47aa65d959 Add checks for placeholder invitation; Fixed bug in member deletion 2025-07-08 16:49:05 +02:00
Gregor Vostrak
b0e638c28b fix daterange presets, fix e2e test 2025-06-30 12:54:22 +02:00
Gregor Vostrak
24b62d4643 add information about placeholders in delete modal 2025-06-30 12:54:22 +02:00
Gregor Vostrak
dd928508fd add delete modal for member delete with relations
allow admins to delete members
fix Dialog cloes on click outside of content
2025-06-30 12:54:22 +02:00
Constantin Graf
ead9cf2185 Add option to delete members with relations 2025-06-30 12:54:22 +02:00
Gregor Vostrak
7578beb271 fix css variables not updating correctly when system theme changes 2025-06-24 15:43:49 +02:00
Constantin Graf
dc21ac8352 Switch organization after accepting invitation 2025-06-10 11:23:53 +02:00
Constantin Graf
4de7868851 Add postgres version matrix to phpunit tests 2025-06-04 21:43:35 +02:00
dependabot[bot]
ffc016a1ec Bump codecov/codecov-action from 5.4.2 to 5.4.3
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.2 to 5.4.3.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.4.2...v5.4.3)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-22 18:32:13 +02:00
Constantin Graf
be69626970 Add permissions to all GitHub actions 2025-05-22 11:04:37 +02:00
Gregor Vostrak
f1dce88dab fix time zone issue in daterangepicker 2025-05-21 12:34:02 -07:00
229 changed files with 8769 additions and 2389 deletions

View File

@@ -10,6 +10,8 @@ on:
- '.github/workflows/build-private.yml' - '.github/workflows/build-private.yml'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
name: Build - Private name: Build - Private
jobs: jobs:
@@ -17,6 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -11,6 +11,12 @@ on:
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env: env:
DOCKERHUB_REPO: solidtime/solidtime DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime GHCR_REPO: ghcr.io/solidtime-io/solidtime
@@ -26,11 +32,6 @@ jobs:
- runs-on: "ubuntu-24.04" - runs-on: "ubuntu-24.04"
platform: "linux/amd64" platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
@@ -163,11 +164,6 @@ jobs:
merge: merge:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90 timeout-minutes: 90
needs: needs:
- build - build

View File

@@ -3,6 +3,9 @@ on:
push: push:
branches: branches:
- main - main
permissions:
contents: read
jobs: jobs:
api_docs: api_docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,6 +1,8 @@
name: NPM Build name: NPM Build
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:

View File

@@ -1,6 +1,8 @@
name: NPM Lint name: NPM Lint
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:

View File

@@ -1,6 +1,8 @@
name: Publish API package to NPM name: Publish API package to NPM
on: on:
workflow_dispatch workflow_dispatch
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,6 +1,8 @@
name: Publish UI package to NPM name: Publish UI package to NPM
on: on:
workflow_dispatch workflow_dispatch
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,8 @@
name: NPM Typecheck name: NPM Typecheck
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,5 +1,7 @@
name: Static code analysis (PHPStan) name: Static code analysis (PHPStan)
on: push on: push
permissions:
contents: read
jobs: jobs:
phpstan: phpstan:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,13 +1,18 @@
name: PHPUnit Tests name: PHPUnit Tests
on: push on: push
permissions:
contents: read
jobs: jobs:
phpunit: phpunit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services: services:
pgsql_test: pgsql_test:
image: postgres:15 image: postgres:${{ matrix.postgres_version }}
env: env:
PGPASSWORD: 'root' PGPASSWORD: 'root'
POSTGRES_DB: 'laravel' POSTGRES_DB: 'laravel'
@@ -63,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.2 uses: codecov/codecov-action@v5.4.3
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -1,5 +1,7 @@
name: PHP Linting name: PHP Linting
on: push on: push
permissions:
contents: read
jobs: jobs:
pint: pint:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,5 +1,7 @@
name: Playwright Tests name: Playwright Tests
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -26,7 +26,7 @@ class CreateNewUser implements CreatesNewUsers
/** /**
* Create a newly registered user. * Create a newly registered user.
* *
* @param array<string, string> $input * @param array<string, mixed> $input
* *
* @throws ValidationException * @throws ValidationException
*/ */

View File

@@ -6,7 +6,6 @@ namespace App\Actions\Fortify;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -59,8 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
$user->updateProfilePhoto($input['photo']); $user->updateProfilePhoto($input['photo']);
} }
if ($input['email'] !== $user->email && if ($input['email'] !== $user->email) {
$user instanceof MustVerifyEmail) {
$user->forceFill([ $user->forceFill([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],

View File

@@ -57,7 +57,7 @@ class AddOrganizationMember implements AddsTeamMembers
*/ */
protected function rules(): array protected function rules(): array
{ {
return array_filter([ return [
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
@@ -75,7 +75,7 @@ class AddOrganizationMember implements AddsTeamMembers
Role::Employee->value, Role::Employee->value,
]), ]),
], ],
]); ];
} }
/** /**

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Auth;
use App\Mail\AuthApiTokenExpirationReminderMail;
use App\Mail\AuthApiTokenExpiredMail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
class AuthSendReminderForExpiringApiTokensCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'auth:send-mails-expiring-api-tokens '.
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
}
$this->comment('Sending reminder emails about expiring API tokens...');
$sentMails = 0;
Token::query()
->where('expires_at', '<=', Carbon::now()->addDays(7))
->whereNull('reminder_sent_at')
->with([
'client',
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->isApiToken(true)
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
/** @var Collection<int, Token> $tokens */
foreach ($tokens as $token) {
$user = $token->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new AuthApiTokenExpirationReminderMail($token, $user));
$token->reminder_sent_at = Carbon::now();
$token->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' expiring API token emails...');
$this->comment('Sent emails about expired API tokens');
$sentMails = 0;
Token::query()
->where('expires_at', '<=', Carbon::now())
->whereNull('expired_info_sent_at')
->with([
'client',
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->isApiToken(true)
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
/** @var Collection<int, Token> $tokens */
foreach ($tokens as $token) {
$user = $token->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new AuthApiTokenExpiredMail($token, $user));
$token->expired_info_sent_at = Carbon::now();
$token->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' expired API token emails...');
return self::SUCCESS;
}
}

View File

@@ -18,6 +18,10 @@ class Kernel extends ConsoleKernel
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails')) ->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes(); ->everyTenMinutes();
$schedule->command('auth:send-mails-expiring-api-tokens')
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
->everyTenMinutes();
$schedule->command('self-host:check-for-update') $schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update')) ->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily(); ->twiceDaily();
@@ -28,7 +32,7 @@ class Kernel extends ConsoleKernel
$schedule->command('self-host:database-consistency') $schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency')) ->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->twiceDaily(); ->everySixHours();
} }
/** /**

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryRoundingType: string
{
use LaravelEnumHelper;
case Up = 'up';
case Down = 'down';
case Nearest = 'nearest';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
{
public const string KEY = 'invitation_for_the_email_already_exists';
}

View File

@@ -41,9 +41,7 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
return null; return null;
} }
if (! ($collectingType = $this->openApiTransformer->transform($collectingClassType))) { $collectingType = $this->openApiTransformer->transform($collectingClassType);
return null;
}
$newType = new OpenApiObjectType; $newType = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType)); $newType->addProperty('data', (new ArrayType)->setItems($collectingType));

View File

@@ -15,6 +15,7 @@ use Filament\Resources\Resource;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction; use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction; use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -75,7 +76,8 @@ class FailedJobResource extends Resource
->filters([]) ->filters([])
->bulkActions([ ->bulkActions([
BulkAction::make('retry') BulkAction::make('retry')
->label('Retry') ->icon('heroicon-o-arrow-path')
->label('Retry selected')
->requiresConfirmation() ->requiresConfirmation()
->action(function (Collection $records): void { ->action(function (Collection $records): void {
/** @var FailedJob $record */ /** @var FailedJob $record */
@@ -87,11 +89,13 @@ class FailedJobResource extends Resource
->success() ->success()
->send(); ->send();
}), }),
DeleteBulkAction::make(),
]) ])
->actions([ ->actions([
DeleteAction::make('Delete'), DeleteAction::make(),
ViewAction::make('View'), ViewAction::make(),
Action::make('retry') Action::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry') ->label('Retry')
->requiresConfirmation() ->requiresConfirmation()
->action(function (FailedJob $record): void { ->action(function (FailedJob $record): void {
@@ -109,7 +113,6 @@ class FailedJobResource extends Resource
return [ return [
'index' => ListFailedJobs::route('/'), 'index' => ListFailedJobs::route('/'),
'view' => ViewFailedJobs::route('/{record}'), 'view' => ViewFailedJobs::route('/{record}'),
]; ];
} }
} }

View File

@@ -6,8 +6,8 @@ namespace App\Filament\Resources\FailedJobResource\Pages;
use App\Filament\Resources\FailedJobResource; use App\Filament\Resources\FailedJobResource;
use App\Models\FailedJob; use App\Models\FailedJob;
use Filament\Actions\Action;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Actions\Action;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
@@ -19,7 +19,8 @@ class ListFailedJobs extends ListRecords
{ {
return [ return [
Action::make('retry_all') Action::make('retry_all')
->label('Retry all failed Jobs') ->icon('heroicon-o-arrow-path')
->label('Retry all')
->requiresConfirmation() ->requiresConfirmation()
->action(function (): void { ->action(function (): void {
Artisan::call('queue:retry all'); Artisan::call('queue:retry all');
@@ -30,7 +31,8 @@ class ListFailedJobs extends ListRecords
}), }),
Action::make('delete_all') Action::make('delete_all')
->label('Delete all failed Jobs') ->icon('heroicon-o-trash')
->label('Delete all')
->requiresConfirmation() ->requiresConfirmation()
->color('danger') ->color('danger')
->action(function (): void { ->action(function (): void {

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages; use App\Filament\Resources\TokenResource\Pages;
use App\Models\Passport\Client;
use App\Models\Passport\Token; use App\Models\Passport\Token;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Form; use Filament\Forms\Form;
@@ -40,7 +39,7 @@ class TokenResource extends Resource
->label('Name') ->label('Name')
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('user_id') Forms\Components\Select::make('owner_id')
->label('User') ->label('User')
->relationship(name: 'user', titleAttribute: 'name') ->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name']) ->searchable(['name'])
@@ -79,10 +78,12 @@ class TokenResource extends Resource
Tables\Columns\TextColumn::make('client.name') Tables\Columns\TextColumn::make('client.name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\IconColumn::make('client.personal_access_client') Tables\Columns\IconColumn::make('personal_access_client')
->state(function (Token $token): bool {
return in_array('personal_access', $token->client->grant_types ?? [], true);
})
->boolean() ->boolean()
->label('API token?') ->label('API token?'),
->sortable(),
Tables\Columns\IconColumn::make('revoked') Tables\Columns\IconColumn::make('revoked')
->boolean() ->boolean()
->label('Revoked?') ->label('Revoked?')
@@ -104,17 +105,11 @@ class TokenResource extends Resource
->queries( ->queries(
true: function (Builder $query) { true: function (Builder $query) {
/** @var Builder<Token> $query */ /** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) { return $query->isApiToken();
/** @var Builder<Client> $query */
return $query->where('personal_access_client', true);
});
}, },
false: function (Builder $query) { false: function (Builder $query) {
/** @var Builder<Token> $query */ /** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) { return $query->isApiToken(false);
/** @var Builder<Client> $query */
return $query->where('personal_access_client', false);
});
}, },
blank: function (Builder $query) { blank: function (Builder $query) {
/** @var Builder<Token> $query */ /** @var Builder<Token> $query */

View File

@@ -8,9 +8,12 @@ use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest; use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
use App\Http\Resources\V1\ApiToken\ApiTokenCollection; use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource; use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
use App\Models\Passport\Client;
use App\Models\Passport\Token; use App\Models\Passport\Token;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
class ApiTokenController extends Controller class ApiTokenController extends Controller
{ {
@@ -28,7 +31,10 @@ class ApiTokenController extends Controller
$user = $this->user(); $user = $this->user();
$tokens = $user->tokens() $tokens = $user->tokens()
->where('client_id', '=', config('passport.personal_access_client.id')) ->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
})
->get(); ->get();
return new ApiTokenCollection($tokens); return new ApiTokenCollection($tokens);
@@ -48,15 +54,21 @@ class ApiTokenController extends Controller
{ {
$user = $this->user(); $user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) { try {
throw new PersonalAccessClientIsNotConfiguredException; $token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->getToken();
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
} catch (\RuntimeException $exception) {
report($exception);
if (Str::contains($exception->getMessage(), ['Personal access client not found'])) {
throw new PersonalAccessClientIsNotConfiguredException;
}
throw $exception;
} }
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->token;
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
} }
/** /**
@@ -71,13 +83,10 @@ class ApiTokenController extends Controller
{ {
$user = $this->user(); $user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) { if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user'); throw new AuthorizationException('API token does not belong to user');
} }
if ($apiToken->client_id !== config('passport.personal_access_client.id')) { if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {
throw new AuthorizationException('API token is not a personal access token'); throw new AuthorizationException('API token is not a personal access token');
} }
@@ -97,13 +106,10 @@ class ApiTokenController extends Controller
{ {
$user = $this->user(); $user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) { if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user'); throw new AuthorizationException('API token does not belong to user');
} }
if ($apiToken->client_id !== config('passport.personal_access_client.id')) { if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {
throw new AuthorizationException('API token is not a personal access token'); throw new AuthorizationException('API token is not a personal access token');
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest; use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
@@ -50,6 +51,7 @@ class InvitationController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException * @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws InvitationForTheEmailAlreadyExistsApiException
* *
* @operationId invite * @operationId invite
*/ */

View File

@@ -10,12 +10,14 @@ use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember; use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException; use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberDestroyRequest;
use App\Http\Requests\V1\Member\MemberIndexRequest; use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberMergeIntoRequest; use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest; use App\Http\Requests\V1\Member\MemberUpdateRequest;
@@ -100,11 +102,13 @@ class MemberController extends Controller
* *
* @operationId removeMember * @operationId removeMember
*/ */
public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse public function destroy(MemberDestroyRequest $request, Organization $organization, Member $member, MemberService $memberService): JsonResponse
{ {
$this->checkPermission($organization, 'members:delete', $member); $this->checkPermission($organization, 'members:delete', $member);
$memberService->removeMember($member, $organization); $deleteRelated = $request->getDeleteRelated();
$memberService->removeMember($member, $organization, $deleteRelated);
return response() return response()
->json(null, 204); ->json(null, 204);
@@ -170,6 +174,7 @@ class MemberController extends Controller
* @throws UserNotPlaceholderApiException * @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException * @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException * @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
* @throws InvitationForTheEmailAlreadyExistsApiException
* *
* @operationId invitePlaceholder * @operationId invitePlaceholder
*/ */

View File

@@ -73,7 +73,9 @@ class ReportController extends Controller
false, false,
$report->properties->start, $report->properties->start,
$report->properties->end, $report->properties->end,
true true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
); );
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(), $timeEntriesQuery->clone(),
@@ -84,7 +86,9 @@ class ReportController extends Controller
true, true,
$report->properties->start, $report->properties->start,
$report->properties->end, $report->properties->end,
true true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
); );
return new DetailedWithDataReportResource($report, $data, $historyData); return new DetailedWithDataReportResource($report, $data, $historyData);

View File

@@ -107,6 +107,8 @@ class ReportController extends Controller
} }
} }
$properties->timezone = $timezone; $properties->timezone = $timezone;
$properties->roundingType = $request->getPropertyRoundingType();
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
$report->properties = $properties; $report->properties = $properties;
if ($isPublic) { if ($isPublic) {
$report->share_secret = $reportService->generateSecret(); $report->share_secret = $reportService->generateSecret();

View File

@@ -33,6 +33,7 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport; use App\Service\ReportExport\TimeEntriesReportExport;
use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use App\Service\TimeEntryService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Gotenberg\Exceptions\GotenbergApiErrored; use Gotenberg\Exceptions\GotenbergApiErrored;
use Gotenberg\Exceptions\NoOutputFileInResponse; use Gotenberg\Exceptions\NoOutputFileInResponse;
@@ -47,6 +48,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
@@ -140,8 +142,15 @@ class TimeEntryController extends Controller
*/ */
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{ {
$select = TimeEntry::SELECT_COLUMNS;
if ($request->getRoundingType() !== null && $request->getRoundingMinutes() !== null) {
$select = array_diff($select, ['start', 'end']);
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($request->getRoundingType(), $request->getRoundingMinutes()).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($request->getRoundingType(), $request->getRoundingMinutes()).' as end');
}
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->select($select)
->orderBy('start', 'desc'); ->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery); $filter = new TimeEntryFilter($timeEntriesQuery);
@@ -183,6 +192,8 @@ class TimeEntryController extends Controller
$user = $this->user(); $user = $this->user();
$timezone = $user->timezone; $timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([ $timeEntriesQuery->with([
@@ -207,8 +218,9 @@ class TimeEntryController extends Controller
if ($viewFile === false) { if ($viewFile === false) {
throw new \LogicException('View file not found'); throw new \LogicException('View file not found');
} }
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesQuery->clone()->reorder()->withOnly([]), $timeEntriesAggregateQuery,
null, null,
null, null,
$user->timezone, $user->timezone,
@@ -216,7 +228,9 @@ class TimeEntryController extends Controller
false, false,
null, null,
null, null,
$showBillableRate $showBillableRate,
$roundingType,
$roundingMinutes,
); );
$html = Blade::render($viewFile, [ $html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(), 'timeEntries' => $timeEntriesQuery->get(),
@@ -324,6 +338,8 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery, $timeEntriesAggregateQuery,
@@ -334,7 +350,9 @@ class TimeEntryController extends Controller
$request->getFillGapsInTimeGroups(), $request->getFillGapsInTimeGroups(),
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd(),
$showBillableRate $showBillableRate,
$roundingType,
$roundingMinutes
); );
return [ return [
@@ -373,6 +391,8 @@ class TimeEntryController extends Controller
$group = $request->getGroup(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(), $timeEntriesAggregateQuery->clone(),
@@ -383,7 +403,9 @@ class TimeEntryController extends Controller
false, false,
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd(),
$showBillableRate $showBillableRate,
$roundingType,
$roundingMinutes
); );
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries( $dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(), $timeEntriesAggregateQuery->clone(),
@@ -394,7 +416,9 @@ class TimeEntryController extends Controller
true, true,
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd(),
$showBillableRate $showBillableRate,
$roundingType,
$roundingMinutes
); );
$currency = $organization->currency; $currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
@@ -477,7 +501,7 @@ class TimeEntryController extends Controller
/** /**
* @return Builder<TimeEntry> * @return Builder<TimeEntry>
*/ */
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{ {
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization'); ->whereBelongsTo($organization, 'organization');

View File

@@ -43,7 +43,10 @@ class Controller extends BaseController
/** @var Member|null $member */ /** @var Member|null $member */
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first(); $member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) { if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization'); Log::error('This function should only be called in authenticated context after checking the user is a member of the organization', [
'user' => $user->getKey(),
'organization' => $organization->getKey(),
]);
throw new AuthorizationException; throw new AuthorizationException;
} }

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
@@ -20,8 +19,7 @@ class EnsureEmailIsVerified
{ {
if (! app()->isLocal()) { if (! app()->isLocal()) {
if ($request->user() === null || if ($request->user() === null ||
($request->user() instanceof MustVerifyEmail && (! $request->user()->hasVerifiedEmail())) {
! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson() return $request->expectsJson()
? abort(403, 'Your email address is not verified.') ? abort(403, 'Your email address is not verified.')
: Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice')); : Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));

View File

@@ -50,7 +50,7 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [ return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing, 'has_invoicing_extension' => $hasInvoicing,
'billing' => $billing !== null && $currentOrganization !== null ? [ 'billing' => $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization), 'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization), 'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(), 'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),

View File

@@ -26,7 +26,7 @@ class ShareInertiaData
{ {
/** @var PermissionStore $permissions */ /** @var PermissionStore $permissions */
$permissions = app(PermissionStore::class); $permissions = app(PermissionStore::class);
Inertia::share(array_filter([ Inertia::share([
'jetstream' => function () use ($request) { 'jetstream' => function () use ($request) {
/** @var User|null $user */ /** @var User|null $user */
$user = $request->user(); $user = $request->user();
@@ -101,7 +101,7 @@ class ShareInertiaData
return [$key => $bag->messages()]; return [$key => $bag->messages()];
})->all(); })->all();
}, },
])); ]);
return $next($request); return $next($request);
} }

View File

@@ -7,11 +7,8 @@ namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role; use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
@@ -29,10 +26,6 @@ class InvitationStoreRequest extends BaseFormRequest
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.invitation_already_exists'),
], ],
'role' => [ 'role' => [
'required', 'required',

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* @property Organization $organization
*/
class MemberDestroyRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'delete_related' => [
'string',
'in:true,false',
],
];
}
public function getDeleteRelated(): bool
{
return $this->input('delete_related', 'false') === 'true';
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
@@ -128,6 +129,18 @@ class ReportStoreRequest extends BaseFormRequest
'nullable', 'nullable',
'timezone:all', 'timezone:all',
], ],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'properties.rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'properties.rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
]; ];
} }
@@ -205,4 +218,22 @@ class ReportStoreRequest extends BaseFormRequest
{ {
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group')); return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
} }
public function getPropertyRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->input('properties.rounding_type'));
}
public function getPropertyRoundingMinutes(): ?int
{
if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {
return null;
}
return (int) $this->input('properties.rounding_minutes');
}
} }

View File

@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -164,6 +165,18 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
]; ];
} }
@@ -211,4 +224,22 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
{ {
return ExportFormat::from($this->validated('format')); return ExportFormat::from($this->validated('format'));
} }
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -146,6 +147,18 @@ class TimeEntryAggregateRequest extends BaseFormRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
]; ];
} }
@@ -173,4 +186,22 @@ class TimeEntryAggregateRequest extends BaseFormRequest
{ {
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null; return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
} }
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TimeEntryRoundingType;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -133,6 +134,18 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
]; ];
} }
@@ -170,4 +183,22 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{ {
return ExportFormat::from($this->validated('format')); return ExportFormat::from($this->validated('format'));
} }
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -11,8 +12,10 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -23,7 +26,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, array<string|ValidationRule>> * @return array<string, array<string|ValidationRule|RuleContract>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -136,6 +139,18 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
]; ];
} }
@@ -153,4 +168,22 @@ class TimeEntryIndexRequest extends BaseFormRequest
{ {
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0; return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
} }
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
} }

View File

@@ -8,15 +8,11 @@ use App\Http\Resources\PaginatedResourceCollection;
use App\Models\Project; use App\Models\Project;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
private bool $showBillableRates; private bool $showBillableRates;
/**
* @param LengthAwarePaginator<Project> $resource
*/
public function __construct($resource, bool $showBillableRates) public function __construct($resource, bool $showBillableRates)
{ {
parent::__construct($resource); parent::__construct($resource);

View File

@@ -58,6 +58,10 @@ class DetailedReportResource extends BaseResource
'tag_ids' => $this->resource->properties->tagIds?->toArray(), 'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */ /** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(), 'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */
'rounding_type' => $this->resource->properties->roundingType?->value,
/** @var int|null $rounding_minutes Rounding minutes for time entries */
'rounding_minutes' => $this->resource->properties->roundingMinutes,
], ],
/** @var string $created_at Date when the report was created */ /** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class AuthApiTokenExpirationReminderMail extends Mailable
{
use Queueable, SerializesModels;
public Token $token;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Token $token, User $user)
{
$this->token = $token;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.auth-api-expiration-reminder', [
'profileUrl' => URL::to('user/profile'),
'tokenName' => $this->token->name,
])
->subject(__('Your API token will expire in 7 days!'));
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class AuthApiTokenExpiredMail extends Mailable
{
use Queueable, SerializesModels;
public Token $token;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Token $token, User $user)
{
$this->token = $token;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.auth-api-token-expired', [
'profileUrl' => URL::to('user/profile'),
'tokenName' => $this->token->name,
])
->subject(__('Your API token has expired!'));
}
}

View File

@@ -16,8 +16,8 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string $event * @property string $event
* @property string $auditable_type * @property string $auditable_type
* @property string $auditable_id * @property string $auditable_id
* @property array|null $old_values * @property array<string, mixed>|null $old_values
* @property array|null $new_values * @property array<string, mixed>|null $new_values
* @property string|null $url * @property string|null $url
* @property string|null $ip_address * @property string|null $ip_address
* @property string|null $user_agent * @property string|null $user_agent

View File

@@ -47,7 +47,7 @@ class Client extends Model implements AuditableContract
]; ];
/** /**
* @return BelongsTo<Organization, Client> * @return BelongsTo<Organization, $this>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -55,7 +55,7 @@ class Client extends Model implements AuditableContract
} }
/** /**
* @return HasMany<Project> * @return HasMany<Project, $this>
*/ */
public function projects(): HasMany public function projects(): HasMany
{ {

View File

@@ -25,8 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Organization $organization * @property-read Organization $organization
* @property-read User $user * @property-read User $user
* @property-read Collection<ProjectMember> $projectMembers * @property-read Collection<int, ProjectMember> $projectMembers
* @property-read Collection<TimeEntry> $timeEntries * @property-read Collection<int, TimeEntry> $timeEntries
* *
* @method static MemberFactory factory() * @method static MemberFactory factory()
*/ */
@@ -47,7 +47,7 @@ class Member extends JetstreamMembership implements AuditableContract
protected $table = 'members'; protected $table = 'members';
/** /**
* @return BelongsTo<User, Member> * @return BelongsTo<User, $this>
*/ */
public function user(): BelongsTo public function user(): BelongsTo
{ {
@@ -55,7 +55,7 @@ class Member extends JetstreamMembership implements AuditableContract
} }
/** /**
* @return BelongsTo<Organization, Member> * @return BelongsTo<Organization, $this>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -63,7 +63,7 @@ class Member extends JetstreamMembership implements AuditableContract
} }
/** /**
* @return HasMany<TimeEntry> * @return HasMany<TimeEntry, $this>
*/ */
public function timeEntries(): HasMany public function timeEntries(): HasMany
{ {
@@ -71,7 +71,7 @@ class Member extends JetstreamMembership implements AuditableContract
} }
/** /**
* @return HasMany<ProjectMember> * @return HasMany<ProjectMember, $this>
*/ */
public function projectMembers(): HasMany public function projectMembers(): HasMany
{ {

View File

@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamCreated;
@@ -47,7 +48,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property IntervalFormat $interval_format * @property IntervalFormat $interval_format
* @property TimeFormat $time_format * @property TimeFormat $time_format
* *
* @method HasMany<OrganizationInvitation> teamInvitations() * @method HasMany<OrganizationInvitation, $this> teamInvitations()
* @method static OrganizationFactory factory() * @method static OrganizationFactory factory()
*/ */
class Organization extends JetstreamTeam implements AuditableContract class Organization extends JetstreamTeam implements AuditableContract
@@ -79,7 +80,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
* @var array<int, string> * @var list<string>
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name',
@@ -125,7 +126,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/** /**
* Get all the users that belong to the team. * Get all the users that belong to the team.
* *
* @return BelongsToMany<User> * @return BelongsToMany<User, $this, Pivot, 'membership'>
*/ */
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
@@ -142,7 +143,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/** /**
* Get the owner of the team. * Get the owner of the team.
* *
* @return BelongsTo<User, Organization> * @return BelongsTo<User, $this>
*/ */
public function owner(): BelongsTo public function owner(): BelongsTo
{ {
@@ -150,7 +151,7 @@ class Organization extends JetstreamTeam implements AuditableContract
} }
/** /**
* @return HasMany<Member> * @return HasMany<Member, $this>
*/ */
public function members(): HasMany public function members(): HasMany
{ {
@@ -158,7 +159,7 @@ class Organization extends JetstreamTeam implements AuditableContract
} }
/** /**
* @return BelongsToMany<User> * @return BelongsToMany<User, $this, Pivot, 'membership'>
*/ */
public function realUsers(): BelongsToMany public function realUsers(): BelongsToMany
{ {

View File

@@ -53,7 +53,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
/** /**
* Get the organization that the invitation belongs to. * Get the organization that the invitation belongs to.
* *
* @return BelongsTo<Organization, OrganizationInvitation> * @return BelongsTo<Organization, $this>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -63,7 +63,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
/** /**
* Get the organization that the invitation belongs to. * Get the organization that the invitation belongs to.
* *
* @return BelongsTo<Organization, OrganizationInvitation> * @return BelongsTo<Organization, $this>
*/ */
public function team(): BelongsTo public function team(): BelongsTo
{ {

View File

@@ -4,6 +4,26 @@ declare(strict_types=1);
namespace App\Models\Passport; namespace App\Models\Passport;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Passport\AuthCode as PassportAuthCode; use Laravel\Passport\AuthCode as PassportAuthCode;
class AuthCode extends PassportAuthCode {} /**
* @property string $id
* @property string $user_id
* @property string $client_id
* @property string|null $scopes
* @property bool $revoked
* @property Carbon $expires_at
*/
class AuthCode extends PassportAuthCode
{
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -5,22 +5,36 @@ declare(strict_types=1);
namespace App\Models\Passport; namespace App\Models\Passport;
use Database\Factories\Passport\ClientFactory; use Database\Factories\Passport\ClientFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Carbon;
use Laravel\Passport\Client as PassportClient; use Laravel\Passport\Client as PassportClient;
/** /**
* @property string $id * @property string $id
* @property string|null $user_id * @property string|null $owner_id
* @property string|null $owner_type
* @property string $name * @property string $name
* @property string|null $secret * @property string|null $secret
* @property string|null $provider * @property string|null $provider
* @property string $redirect * @property array<string> $grant_types
* @property bool $personal_access_client * @property array<string> $redirect_uris
* @property bool $password_client * @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property bool $revoked * @property bool $revoked
*/ */
class Client extends PassportClient class Client extends PassportClient
{ {
/** @use HasFactory<ClientFactory> */ /** @use HasFactory<ClientFactory> */
use HasFactory; use HasFactory;
/**
* Create a new factory instance for the model.
*
* @return ClientFactory
*/
protected static function newFactory(): Factory
{
return ClientFactory::new();
}
} }

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
class PersonalAccessClient extends PassportPersonalAccessClient {}

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Models\Passport; namespace App\Models\Passport;
use App\Models\User;
use Database\Factories\Passport\TokenFactory; use Database\Factories\Passport\TokenFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -17,9 +19,15 @@ use Laravel\Passport\Token as PassportToken;
* @property null|string $name * @property null|string $name
* @property array<string> $scopes * @property array<string> $scopes
* @property bool $revoked * @property bool $revoked
* @property Carbon|null $reminder_sent_at
* @property Carbon|null $expired_info_sent_at
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property Carbon|null $expires_at * @property Carbon|null $expires_at
* @property-read Client|null $client
* @property-read User|null $user
*
* @method Builder<Token> isApiToken(bool $isApiToken = true)
*/ */
class Token extends PassportToken class Token extends PassportToken
{ {
@@ -29,10 +37,60 @@ class Token extends PassportToken
/** /**
* Get the client that the token belongs to. * Get the client that the token belongs to.
* *
* @return BelongsTo<Client, Token> * @return BelongsTo<Client, $this>
*/ */
// @phpstan-ignore method.childReturnType
public function client(): BelongsTo public function client(): BelongsTo
{ {
return $this->belongsTo(Client::class, 'client_id', 'id'); return $this->belongsTo(Client::class, 'client_id', 'id');
} }
/**
* Get the user that the token belongs to.
*
* @deprecated Will be removed in a future Laravel version.
*
* @return BelongsTo<User, $this>
*/
// @phpstan-ignore method.childReturnType
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'scopes' => 'array',
'revoked' => 'bool',
'expires_at' => 'datetime',
'reminder_sent_at' => 'datetime',
'expired_info_sent_at' => 'datetime',
];
}
/**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder
{
if ($isApiToken) {
return $query->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
});
} else {
return $query->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonDoesntContain('grant_types', 'personal_access');
});
}
}
} }

View File

@@ -137,7 +137,7 @@ class Project extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Organization, Project> * @return BelongsTo<Organization, $this>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -145,7 +145,7 @@ class Project extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Client, Project> * @return BelongsTo<Client, $this>
*/ */
public function client(): BelongsTo public function client(): BelongsTo
{ {
@@ -153,7 +153,7 @@ class Project extends Model implements AuditableContract
} }
/** /**
* @return HasMany<ProjectMember> * @return HasMany<ProjectMember, $this>
*/ */
public function members(): HasMany public function members(): HasMany
{ {
@@ -161,7 +161,7 @@ class Project extends Model implements AuditableContract
} }
/** /**
* @return HasMany<Task> * @return HasMany<Task, $this>
*/ */
public function tasks(): HasMany public function tasks(): HasMany
{ {
@@ -169,7 +169,7 @@ class Project extends Model implements AuditableContract
} }
/** /**
* @return HasMany<TimeEntry> * @return HasMany<TimeEntry, $this>
*/ */
public function timeEntries(): HasMany public function timeEntries(): HasMany
{ {

View File

@@ -48,7 +48,7 @@ class ProjectMember extends Model implements AuditableContract
]; ];
/** /**
* @return BelongsTo<Project, ProjectMember> * @return BelongsTo<Project, $this>
*/ */
public function project(): BelongsTo public function project(): BelongsTo
{ {
@@ -58,7 +58,7 @@ class ProjectMember extends Model implements AuditableContract
/** /**
* @deprecated Use member relationship instead * @deprecated Use member relationship instead
* *
* @return BelongsTo<User, ProjectMember> * @return BelongsTo<User, $this>
*/ */
public function user(): BelongsTo public function user(): BelongsTo
{ {
@@ -66,7 +66,7 @@ class ProjectMember extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Member, ProjectMember> * @return BelongsTo<Member, $this>
*/ */
public function member(): BelongsTo public function member(): BelongsTo
{ {

View File

@@ -55,7 +55,7 @@ class Report extends Model
} }
/** /**
* @return BelongsTo<Organization, Report> * @return BelongsTo<Organization, $this>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {

View File

@@ -22,7 +22,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
* @property string $organization_id * @property string $organization_id
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Collection<TimeEntry> $timeEntries * @property-read Collection<int, TimeEntry> $timeEntries
* @property-read Organization $organization * @property-read Organization $organization
* *
* @method static TagFactory factory() * @method static TagFactory factory()
@@ -47,7 +47,7 @@ class Tag extends Model implements AuditableContract
]; ];
/** /**
* @return BelongsTo<Organization, Tag> * @return BelongsTo<Organization, $this>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {

View File

@@ -120,7 +120,7 @@ class Task extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Project, Task> * @return BelongsTo<Project, $this>
*/ */
public function project(): BelongsTo public function project(): BelongsTo
{ {
@@ -128,7 +128,7 @@ class Task extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Organization, Task> * @return BelongsTo<Organization, $this>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -136,7 +136,7 @@ class Task extends Model implements AuditableContract
} }
/** /**
* @return HasMany<TimeEntry> * @return HasMany<TimeEntry, $this>
*/ */
public function timeEntries(): HasMany public function timeEntries(): HasMany
{ {

View File

@@ -28,7 +28,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property Carbon|null $end * @property Carbon|null $end
* @property int|null $billable_rate Billable rate per hour in cents * @property int|null $billable_rate Billable rate per hour in cents
* @property bool $billable * @property bool $billable
* @property array $tags * @property array<string> $tags
* @property string $user_id * @property string $user_id
* @property string $member_id * @property string $member_id
* @property bool $is_imported * @property bool $is_imported
@@ -45,7 +45,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property-read Client|null $client * @property-read Client|null $client
* @property string|null $task_id * @property string|null $task_id
* @property-read Task|null $task * @property-read Task|null $task
* @property-read Collection<Tag> $tagsRelation * @property-read Collection<int, Tag> $tagsRelation
* *
* @method Builder<TimeEntry> hasTag(Tag $tag) * @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory() * @method static TimeEntryFactory factory()
@@ -77,6 +77,26 @@ class TimeEntry extends Model implements AuditableContract
'still_active_email_sent_at' => 'datetime', 'still_active_email_sent_at' => 'datetime',
]; ];
public const array SELECT_COLUMNS = [
'id',
'description',
'start',
'end',
'billable_rate',
'billable',
'user_id',
'organization_id',
'project_id',
'task_id',
'tags',
'created_at',
'updated_at',
'member_id',
'client_id',
'is_imported',
'still_active_email_sent_at',
];
/** /**
* The attributes that are computed. (f.e. for performance reasons) * The attributes that are computed. (f.e. for performance reasons)
* These attributes can be regenerated at any time. * These attributes can be regenerated at any time.
@@ -154,7 +174,7 @@ class TimeEntry extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<User, TimeEntry> * @return BelongsTo<User, $this>
*/ */
public function user(): BelongsTo public function user(): BelongsTo
{ {
@@ -162,7 +182,7 @@ class TimeEntry extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Member, TimeEntry> * @return BelongsTo<Member, $this>
*/ */
public function member(): BelongsTo public function member(): BelongsTo
{ {
@@ -170,7 +190,7 @@ class TimeEntry extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Organization, TimeEntry> * @return BelongsTo<Organization, $this>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -178,7 +198,7 @@ class TimeEntry extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Project, TimeEntry> * @return BelongsTo<Project, $this>
*/ */
public function project(): BelongsTo public function project(): BelongsTo
{ {
@@ -186,7 +206,7 @@ class TimeEntry extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Task, TimeEntry> * @return BelongsTo<Task, $this>
*/ */
public function task(): BelongsTo public function task(): BelongsTo
{ {
@@ -196,7 +216,7 @@ class TimeEntry extends Model implements AuditableContract
/** /**
* This relation can be reconstructed via the task relation. It is only here for performance reasons. * This relation can be reconstructed via the task relation. It is only here for performance reasons.
* *
* @return BelongsTo<Client, TimeEntry> * @return BelongsTo<Client, $this>
*/ */
public function client(): BelongsTo public function client(): BelongsTo
{ {

View File

@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -27,6 +28,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams; use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode; use Laravel\Passport\AuthCode;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -52,13 +54,13 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Collection<int, TimeEntry> $timeEntries * @property Collection<int, TimeEntry> $timeEntries
* @property Member $membership * @property Member $membership
* *
* @method HasMany<Organization> ownedTeams() * @method HasMany<Organization, $this> ownedTeams()
* @method static UserFactory factory() * @method static UserFactory factory()
* @method static Builder<User> query() * @method static Builder<User> query()
* @method Builder<User> belongsToOrganization(Organization $organization) * @method Builder<User> belongsToOrganization(Organization $organization)
* @method Builder<User> active() * @method Builder<User> active()
*/ */
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail, OAuthenticatable
{ {
use CustomAuditable; use CustomAuditable;
use HasApiTokens; use HasApiTokens;
@@ -75,7 +77,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
* @var array<int, string> * @var list<string>
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name',
@@ -86,7 +88,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/** /**
* The attributes that should be hidden for serialization. * The attributes that should be hidden for serialization.
* *
* @var array<int, string> * @var list<string>
*/ */
protected $hidden = [ protected $hidden = [
'password', 'password',
@@ -143,7 +145,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return BelongsToMany<Organization> * @return BelongsToMany<Organization, $this, Pivot, 'membership'>
*/ */
public function organizations(): BelongsToMany public function organizations(): BelongsToMany
{ {
@@ -158,7 +160,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return HasMany<TimeEntry> * @return HasMany<TimeEntry, $this>
*/ */
public function timeEntries(): HasMany public function timeEntries(): HasMany
{ {
@@ -166,7 +168,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return BelongsTo<Organization, User> * @return BelongsTo<Organization, $this>
*/ */
public function currentOrganization(): BelongsTo public function currentOrganization(): BelongsTo
{ {
@@ -174,7 +176,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return HasMany<ProjectMember> * @return HasMany<ProjectMember, $this>
*/ */
public function projectMembers(): HasMany public function projectMembers(): HasMany
{ {
@@ -182,7 +184,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return HasMany<Token> * @return HasMany<Token, $this>
*/ */
public function accessTokens(): HasMany public function accessTokens(): HasMany
{ {
@@ -190,24 +192,13 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return HasMany<AuthCode> * @return HasMany<AuthCode, $this>
*/ */
public function authCodes(): HasMany public function authCodes(): HasMany
{ {
return $this->hasMany(AuthCode::class); return $this->hasMany(AuthCode::class);
} }
/**
* Get the access tokens for the user.
*
* @return HasMany<Token>
*/
public function tokens(): HasMany
{
return $this->hasMany(Token::class, 'user_id')
->orderBy('created_at', 'desc');
}
/** /**
* @param Builder<User> $builder * @param Builder<User> $builder
*/ */

View File

@@ -7,7 +7,6 @@ namespace App\Providers;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Passport\AuthCode; use App\Models\Passport\AuthCode;
use App\Models\Passport\Client; use App\Models\Passport\Client;
use App\Models\Passport\PersonalAccessClient;
use App\Models\Passport\RefreshToken; use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token; use App\Models\Passport\Token;
use App\Policies\OrganizationPolicy; use App\Policies\OrganizationPolicy;
@@ -51,7 +50,8 @@ class AuthServiceProvider extends ServiceProvider
Passport::useRefreshTokenModel(RefreshToken::class); Passport::useRefreshTokenModel(RefreshToken::class);
Passport::useAuthCodeModel(AuthCode::class); Passport::useAuthCodeModel(AuthCode::class);
Passport::useClientModel(Client::class); Passport::useClientModel(Client::class);
Passport::usePersonalAccessClientModel(PersonalAccessClient::class);
Passport::authorizationView('auth.oauth.authorize');
// Passport::tokensExpireIn(now()->addDays(15)); // Passport::tokensExpireIn(now()->addDays(15));
// Passport::refreshTokensExpireIn(now()->addDays(30)); // Passport::refreshTokensExpireIn(now()->addDays(30));

View File

@@ -187,6 +187,7 @@ class JetstreamServiceProvider extends ServiceProvider
'members:invite-placeholder', 'members:invite-placeholder',
'members:make-placeholder', 'members:make-placeholder',
'members:merge-into', 'members:merge-into',
'members:delete',
'members:update', 'members:update',
'reports:view', 'reports:view',
'reports:create', 'reports:create',

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use Brick\Money\ISOCurrencyProvider;
use Brick\Money\Money; use Brick\Money\Money;
class CurrencyService class CurrencyService
@@ -374,4 +375,12 @@ class CurrencyService
return $currencyCode; return $currencyCode;
} }
public function getRandomCurrencyCode(): string
{
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencyCodes = array_keys($currencies);
return $currencyCodes[array_rand($currencyCodes)];
}
} }

View File

@@ -6,6 +6,7 @@ namespace App\Service\Dto;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
@@ -59,6 +60,10 @@ class ReportPropertiesDto implements Castable
*/ */
public ?Collection $taskIds = null; public ?Collection $taskIds = null;
public ?TimeEntryRoundingType $roundingType = null;
public ?int $roundingMinutes = null;
/** /**
* Get the caster class to use when casting from / to this cast target. * Get the caster class to use when casting from / to this cast target.
* *
@@ -115,13 +120,14 @@ class ReportPropertiesDto implements Castable
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup); $dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
$dto->weekStart = Weekday::from($data->weekStart); $dto->weekStart = Weekday::from($data->weekStart);
$dto->timezone = $data->timezone; $dto->timezone = $data->timezone;
// Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
return $dto; return $dto;
} }
/**
* @param ReportPropertiesDto $value
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string public function set(Model $model, string $key, mixed $value, array $attributes): string
{ {
if (! ($value instanceof ReportPropertiesDto)) { if (! ($value instanceof ReportPropertiesDto)) {
@@ -143,6 +149,8 @@ class ReportPropertiesDto implements Castable
'historyGroup' => $value->historyGroup->value, 'historyGroup' => $value->historyGroup->value,
'weekStart' => $value->weekStart->value, 'weekStart' => $value->weekStart->value,
'timezone' => $value->timezone, 'timezone' => $value->timezone,
'roundingType' => $value->roundingType?->value,
'roundingMinutes' => $value->roundingMinutes,
]; ];
$jsonString = json_encode($data); $jsonString = json_encode($data);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Mail\OrganizationInvitationMail; use App\Mail\OrganizationInvitationMail;
use App\Models\Member; use App\Models\Member;
@@ -16,7 +17,7 @@ use Laravel\Jetstream\Events\InvitingTeamMember;
class InvitationService class InvitationService
{ {
/** /**
* @throws UserIsAlreadyMemberOfOrganizationApiException * @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
*/ */
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{ {
@@ -28,6 +29,13 @@ class InvitationService
throw new UserIsAlreadyMemberOfOrganizationApiException; throw new UserIsAlreadyMemberOfOrganizationApiException;
} }
if (OrganizationInvitation::query()
->where('email', $email)
->whereBelongsTo($organization, 'organization')
->exists()) {
throw new InvitationForTheEmailAlreadyExistsApiException;
}
InvitingTeamMember::dispatch($organization, $email, $role->value); InvitingTeamMember::dispatch($organization, $email, $role->value);
$invitation = new OrganizationInvitation; $invitation = new OrganizationInvitation;

View File

@@ -45,6 +45,9 @@ class MemberService
$member->organization()->associate($organization); $member->organization()->associate($organization);
$member->role = $role->value; $member->role = $role->value;
$member->save(); $member->save();
$user->currentOrganization()->associate($organization);
$user->save();
}); });
if (! $asSuperAdmin) { if (! $asSuperAdmin) {
@@ -58,19 +61,41 @@ class MemberService
* @throws CanNotRemoveOwnerFromOrganization * @throws CanNotRemoveOwnerFromOrganization
* @throws EntityStillInUseApiException * @throws EntityStillInUseApiException
*/ */
public function removeMember(Member $member, Organization $organization): void public function removeMember(Member $member, Organization $organization, bool $withRelations = false): void
{ {
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) { if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization; throw new CanNotRemoveOwnerFromOrganization;
} }
$user = $member->user;
$isPlaceholder = $user->is_placeholder;
if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {
$user->currentTeam()->disassociate();
$user->save();
}
if ($withRelations) {
TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->delete();
ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->delete();
} else {
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
}
$member->delete(); $member->delete();
if ($isPlaceholder) {
$user->delete();
} else {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
MemberRemoved::dispatch($member, $organization); MemberRemoved::dispatch($member, $organization);
} }

View File

@@ -71,7 +71,7 @@ class PermissionStore
/** @var Role|null $roleObj */ /** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role); $roleObj = Jetstream::findRole($role);
return $roleObj?->permissions ?? []; return $roleObj->permissions ?? [];
} }
/** /**

View File

@@ -6,6 +6,7 @@ namespace App\Service;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Client; use App\Models\Client;
use App\Models\Project; use App\Models\Project;
@@ -41,7 +42,7 @@ class TimeEntryAggregationService
* cost: int|null * cost: int|null
* } * }
*/ */
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
{ {
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null; $fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
$group1Select = null; $group1Select = null;
@@ -56,15 +57,14 @@ class TimeEntryAggregationService
} }
} }
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
$timeEntriesQuery->selectRaw( $timeEntriesQuery->selectRaw(
($group1Select !== null ? $group1Select.' as group_1,' : ''). ($group1Select !== null ? $group1Select.' as group_1,' : '').
($group2Select !== null ? $group2Select.' as group_2,' : ''). ($group2Select !== null ? $group2Select.' as group_2,' : '').
' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'. ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round( ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
sum(
extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60)
)
) as cost'
); );
if ($groupBy !== null) { if ($groupBy !== null) {
$timeEntriesQuery->groupBy($groupBy); $timeEntriesQuery->groupBy($groupBy);
@@ -164,9 +164,9 @@ class TimeEntryAggregationService
* cost: int|null * cost: int|null
* } * }
*/ */
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
{ {
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate); $aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);
$keysGroup1 = []; $keysGroup1 = [];
$keysGroup2 = []; $keysGroup2 = [];

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\TimeEntryRoundingType;
use Illuminate\Support\Carbon;
use LogicException;
class TimeEntryService
{
public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
{
if ($roundingType === null || $roundingMinutes === null) {
return 'start';
}
if ($roundingMinutes < 1) {
throw new LogicException('Rounding minutes must be greater than 0');
}
return 'date_bin(\'1 minutes\', start, TIMESTAMP \'1970-01-01\')';
}
public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
{
if ($roundingType === null || $roundingMinutes === null) {
return 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
}
if ($roundingMinutes < 1) {
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
}
}
}

View File

@@ -11,31 +11,31 @@
"datomatic/laravel-enum-helper": "^2.0.0", "datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.12.2", "dedoc/scramble": "^0.12.2",
"filament/filament": "^3.2", "filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.3.0", "flowframe/laravel-trend": "^0.4.0",
"gotenberg/gotenberg-php": "^2.8", "gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^1.0", "inertiajs/inertia-laravel": "^2.0.3",
"korridor/laravel-computed-attributes": "^3.1", "korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1", "korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0", "korridor/laravel-model-validation-rules": "^3.0",
"laravel/framework": "^11.16.0", "laravel/framework": "^12.19.3",
"laravel/jetstream": "^5.0", "laravel/jetstream": "^5.0",
"laravel/octane": "^2.3", "laravel/octane": "^2.3",
"laravel/passport": "^12.0", "laravel/passport": "^13.0.5",
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
"league/csv": "^9.16.0", "league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3", "league/iso3166": "^4.3",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
"novadaemon/filament-pretty-json": "^2.2", "novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^11.0.11", "nwidart/laravel-modules": "^12.0.4",
"owen-it/laravel-auditing": "^13.6", "owen-it/laravel-auditing": "^14.0.0",
"pxlrbt/filament-environment-indicator": "^2.0", "pxlrbt/filament-environment-indicator": "^2.1.0",
"spatie/temporary-directory": "^2.2", "spatie/temporary-directory": "^2.2",
"staudenmeir/eloquent-json-relations": "^1.1", "staudenmeir/eloquent-json-relations": "^1.1",
"stechstudio/filament-impersonate": "^3.8", "stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0", "tightenco/ziggy": "^2.1.0",
"tpetry/laravel-postgresql-enhanced": "^2.0.0", "tpetry/laravel-postgresql-enhanced": "^3.0.0",
"wikimedia/composer-merge-plugin": "^2.1.0" "wikimedia/composer-merge-plugin": "^2.1.0"
}, },
"require-dev": { "require-dev": {
@@ -43,14 +43,13 @@
"brianium/paratest": "^7.3", "brianium/paratest": "^7.3",
"fakerphp/faker": "^1.9.1", "fakerphp/faker": "^1.9.1",
"fumeapp/modeltyper": "^3.0", "fumeapp/modeltyper": "^3.0",
"phpstan/phpstan": "1.12.0", "larastan/larastan": "^3.5.0",
"larastan/larastan": "^2.0",
"laravel/pint": "^1.0", "laravel/pint": "^1.0",
"laravel/sail": "^1.18", "laravel/sail": "^1.18",
"laravel/telescope": "^5.0", "laravel/telescope": "^5.0",
"mockery/mockery": "^1.4.4", "mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^8.1", "nunomaduro/collision": "^8.1",
"phpunit/phpunit": "^11", "phpunit/phpunit": "^12",
"spatie/laravel-ignition": "^2.0", "spatie/laravel-ignition": "^2.0",
"timacdonald/log-fake": "^2.1" "timacdonald/log-fake": "^2.1"
}, },
@@ -119,7 +118,8 @@
"extra": { "extra": {
"laravel": { "laravel": {
"dont-discover": [ "dont-discover": [
"laravel/telescope" "laravel/telescope",
"nwidart/laravel-modules"
] ]
} }
}, },

2458
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat; use App\Enums\TimeFormat;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\LaravelModulesServiceProvider;
return [ return [
@@ -197,6 +198,7 @@ return [
App\Providers\FortifyServiceProvider::class, App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class, App\Providers\JetstreamServiceProvider::class,
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider // Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
LaravelModulesServiceProvider::class,
])->toArray(), ])->toArray(),
/* /*

View File

@@ -34,31 +34,15 @@ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Client UUIDs | Passport Database Connection
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| By default, Passport uses auto-incrementing primary keys when assigning | By default, Passport's models will utilize your application's default
| IDs to clients. However, if Passport is installed using the provided | database connection. If you wish to use a different connection you
| --uuids switch, this will be set to "true" and UUIDs will be used. | may specify the configured name of the database connection here.
| |
*/ */
'client_uuids' => true, 'connection' => env('PASSPORT_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Personal Access Client
|--------------------------------------------------------------------------
|
| If you enable client hashing, you should set the personal access client
| ID and unhashed secret within your environment file. The values will
| get used while issuing fresh personal access tokens to your users.
|
*/
'personal_access_client' => [
'id' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_ID'),
'secret' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET'),
],
]; ];

View File

@@ -6,6 +6,7 @@ return [
'tasks' => [ 'tasks' => [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true), 'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true),
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', 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), 'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false), 'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),

View File

@@ -11,6 +11,7 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat; use App\Enums\TimeFormat;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\CurrencyService;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@@ -27,7 +28,7 @@ class OrganizationFactory extends Factory
{ {
return [ return [
'name' => $this->faker->unique()->company(), 'name' => $this->faker->unique()->company(),
'currency' => $this->faker->currencyCode(), 'currency' => app(CurrencyService::class)->getRandomCurrencyCode(),
'billable_rate' => null, 'billable_rate' => null,
'user_id' => User::factory(), 'user_id' => User::factory(),
'personal_team' => true, 'personal_team' => true,

View File

@@ -7,11 +7,12 @@ namespace Database\Factories\Passport;
use App\Models\Passport\Client; use App\Models\Passport\Client;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Laravel\Passport\Database\Factories\ClientFactory as BaseClientFactory;
/** /**
* @extends Factory<Client> * @extends Factory<Client>
*/ */
class ClientFactory extends Factory class ClientFactory extends BaseClientFactory
{ {
/** /**
* Define the model's default state. * Define the model's default state.
@@ -22,24 +23,40 @@ class ClientFactory extends Factory
{ {
return [ return [
'id' => $this->faker->uuid, 'id' => $this->faker->uuid,
'user_id' => null, 'owner_id' => null,
'owner_type' => null,
'name' => $this->faker->company(), 'name' => $this->faker->company(),
'secret' => $this->faker->regexify('[A-Za-z]{40}'), 'secret' => $this->faker->regexify('[A-Za-z]{40}'),
'provider' => 'users', 'provider' => 'users',
'redirect' => $this->faker->url(), 'redirect_uris' => [$this->faker->url()],
'personal_access_client' => false, 'grant_types' => [],
'password_client' => false,
'revoked' => false, 'revoked' => false,
'created_at' => $this->faker->dateTime(), 'created_at' => $this->faker->dateTime(),
'updated_at' => $this->faker->dateTime(), 'updated_at' => $this->faker->dateTime(),
]; ];
} }
public function desktopClient(): self
{
return $this->state(fn (array $attributes) => [
'name' => 'Desktop',
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'authorization_code', 'implicit'],
]);
}
public function apiClient(): self
{
return $this->state(fn (array $attributes) => [
'name' => 'API',
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'client_credentials', 'personal_access'],
]);
}
public function personalAccessClient(): self public function personalAccessClient(): self
{ {
return $this->state(function (array $attributes) { return $this->state(function (array $attributes) {
return [ return [
'personal_access_client' => true, 'grant_types' => ['personal_access'],
]; ];
}); });
} }
@@ -48,7 +65,8 @@ class ClientFactory extends Factory
{ {
return $this->state(function (array $attributes) use ($user): array { return $this->state(function (array $attributes) use ($user): array {
return [ return [
'user_id' => $user->getKey(), 'owner_id' => $user->getKey(),
'owner_type' => (new User)->getMorphClass(),
]; ];
}); });
} }

View File

@@ -31,6 +31,8 @@ class TokenFactory extends Factory
'created_at' => $this->faker->dateTime, 'created_at' => $this->faker->dateTime,
'updated_at' => $this->faker->dateTime, 'updated_at' => $this->faker->dateTime,
'expires_at' => $this->faker->dateTime, 'expires_at' => $this->faker->dateTime,
'reminder_sent_at' => null,
'expired_info_sent_at' => null,
]; ];
} }

View File

@@ -153,6 +153,16 @@ class TimeEntryFactory extends Factory
}); });
} }
public function endWithDuration(Carbon $end, int $durationInSeconds): self
{
return $this->state(function (array $attributes) use ($end, $durationInSeconds): array {
return [
'start' => $end->copy()->utc()->subSeconds($durationInSeconds),
'end' => $end->copy()->utc(),
];
});
}
public function start(Carbon $start): self public function start(Carbon $start): self
{ {
return $this->state(function (array $attributes) use ($start): array { return $this->state(function (array $attributes) use ($start): array {

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_device_codes', function (Blueprint $table): void {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id')->index();
$table->char('user_code', 8)->unique();
$table->text('scopes');
$table->boolean('revoked');
$table->dateTime('user_approved_at')->nullable();
$table->dateTime('last_polled_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_device_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::drop('oauth_personal_access_clients');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::create('oauth_personal_access_clients', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->uuid('client_id');
$table->foreign('client_id')
->references('id')
->on('oauth_clients')
->onDelete('restrict')
->onUpdate('cascade');
$table->timestamps();
});
}
};

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('oauth_clients')->update(['provider' => 'users']); // Change default provider if necessary
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->text('grant_types')->default('[]')->after('provider');
$table->text('redirect_uris')->default('[]');
$table->renameColumn('user_id', 'owner_id');
$table->string('owner_type')->after('owner_id')->nullable();
});
DB::table('oauth_clients')
->where('redirect', '=', 'http://localhost')
->where('personal_access_client', '=', true)
->update(['redirect' => '']);
DB::table('oauth_clients')
->whereNotNull('owner_id')
->update(['owner_type' => 'user']); // Value might be class name of the owner model, depends on if you use "enforceMorphMap"
DB::table('oauth_clients')->eachById(function ($client): void {
$grantTypes = ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'];
$confidential = ! empty($client->secret);
$noRedirect = empty($client->redirect);
$redirectUris = $noRedirect ? [] : [$client->redirect];
$firstParty = empty($client->owner_id);
if (! $noRedirect) {
$grantTypes[] = 'authorization_code';
$grantTypes[] = 'implicit';
}
if ($confidential && $firstParty) {
$grantTypes[] = 'client_credentials';
}
if ($client->personal_access_client && $confidential) {
$grantTypes[] = 'personal_access';
}
if ($client->password_client) {
$grantTypes[] = 'password';
}
DB::table('oauth_clients')
->where('id', $client->id)
->update([
'redirect_uris' => $redirectUris,
'grant_types' => $grantTypes,
]);
});
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->dropForeign(['user_id']);
$table->index(['owner_id', 'owner_type']);
$table->dropColumn('redirect');
$table->dropColumn('personal_access_client');
$table->dropColumn('password_client');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->dropIndex(['owner_id', 'owner_type']);
$table->renameColumn('owner_id', 'user_id');
$table->foreign('user_id')
->on('users')
->references('id')
->onDelete('cascade')
->onUpdate('cascade');
$table->string('redirect')->nullable();
$table->boolean('personal_access_client')->default(false);
$table->boolean('password_client')->default(false);
});
DB::table('oauth_clients')->eachById(function ($client): void {
$redirectUris = json_decode($client->redirect_uris);
$grantTypes = json_decode($client->grant_types);
DB::table('oauth_clients')
->where('id', $client->id)
->update([
'redirect' => $redirectUris[0] ?? '', // redirect not nullable
'password_client' => in_array('password', $grantTypes, true)
&& in_array('refresh_token', $grantTypes, true),
'personal_access_client' => in_array('personal_access', $grantTypes, true),
]);
});
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->dropColumn(['grant_types', 'redirect_uris', 'owner_type']);
$table->string('redirect')->nullable(false)->change();
$table->boolean('personal_access_client')->default(null)->change();
$table->boolean('password_client')->default(null)->change();
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// This could be optimized to run all the updates in the eachById
DB::table('oauth_clients')->whereNotNull('secret')->eachById(function ($client): void {
$secret = $client->secret;
if (Hash::isHashed($secret) && ! Hash::needsRehash($secret)) {
return; // Already hashed and not needing rehash
}
DB::table('oauth_clients')
->where('id', $client->id)
->update([
'secret' => Hash::make($secret),
]);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// This can not be reversed without a backup of the original secrets, for security reasons.
}
};

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
$table->dateTime('reminder_sent_at')->nullable();
$table->dateTime('expired_info_sent_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
$table->dropColumn('reminder_sent_at');
$table->dropColumn('expired_info_sent_at');
});
}
};

View File

@@ -24,7 +24,6 @@ use Illuminate\Support\Facades\DB;
use Laravel\Passport\AuthCode; use Laravel\Passport\AuthCode;
use Laravel\Passport\Client as PassportClient; use Laravel\Passport\Client as PassportClient;
use Laravel\Passport\ClientRepository; use Laravel\Passport\ClientRepository;
use Laravel\Passport\PersonalAccessClient;
use Laravel\Passport\RefreshToken; use Laravel\Passport\RefreshToken;
use Laravel\Passport\Token; use Laravel\Passport\Token;
@@ -37,6 +36,18 @@ class DatabaseSeeder extends Seeder
{ {
$this->deleteAll(); $this->deleteAll();
app(ClientRepository::class)->createAuthorizationCodeGrantClient(
name: 'Desktop App',
redirectUris: ['solidtime://oauth/callback'],
confidential: false, // TODO: ?
enableDeviceFlow: false, // TODO: ?
);
// TODO: grant_types ? migration?
// app(ClientRepository::class)->createPersonalAccessGrantClient('API');
/*
app(ClientRepository::class)->create( app(ClientRepository::class)->create(
null, null,
'desktop', 'desktop',
@@ -46,17 +57,16 @@ class DatabaseSeeder extends Seeder
false, false,
false false
); );
*/
$personalAccessClient = new PassportClient; $personalAccessClient = new PassportClient;
$personalAccessClient->id = config('passport.personal_access_client.id'); $personalAccessClient->id = config('passport.personal_access_client.id');
$personalAccessClient->secret = config('passport.personal_access_client.secret'); $personalAccessClient->secret = config('passport.personal_access_client.secret');
$personalAccessClient->name = 'API'; $personalAccessClient->name = 'API';
$personalAccessClient->redirect = 'http://localhost'; $personalAccessClient->redirect_uris = ['http://localhost'];
$personalAccessClient->user_id = null;
$personalAccessClient->revoked = false; $personalAccessClient->revoked = false;
$personalAccessClient->provider = null; $personalAccessClient->provider = 'users';
$personalAccessClient->personal_access_client = true; $personalAccessClient->grant_types = ['personal_access'];
$personalAccessClient->password_client = false;
$personalAccessClient->save(); $personalAccessClient->save();
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([ $userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
@@ -197,7 +207,6 @@ class DatabaseSeeder extends Seeder
DB::table((new RefreshToken)->getTable())->delete(); DB::table((new RefreshToken)->getTable())->delete();
DB::table((new Token)->getTable())->delete(); DB::table((new Token)->getTable())->delete();
DB::table((new AuthCode)->getTable())->delete(); DB::table((new AuthCode)->getTable())->delete();
DB::table((new PersonalAccessClient)->getTable())->delete();
DB::table((new PassportClient)->getTable())->delete(); DB::table((new PassportClient)->getTable())->delete();
// Internal tables // Internal tables

View File

@@ -1,47 +1,30 @@
# Accepted values: 8.3 - 8.2
ARG PHP_VERSION=8.3 ARG PHP_VERSION=8.3
ARG FRANKENPHP_VERSION=1.8
ARG FRANKENPHP_VERSION=latest ARG COMPOSER_VERSION=2.8
ARG BUN_VERSION="latest"
ARG COMPOSER_VERSION=latest ARG APP_ENV
ARG DOCKER_FILES_BASE_PATH="docker/prod/" ARG DOCKER_FILES_BASE_PATH="docker/prod/"
###########################################
# Build frontend assets with NPM
###########################################
#ARG NODE_VERSION=20-alpine
#
#FROM node:${NODE_VERSION} AS build
#
#ENV ROOT=/var/www/html
#
#WORKDIR ${ROOT}
#
#RUN npm config set update-notifier false && npm set progress=false
#
#COPY package*.json ./
#
#RUN if [ -f $ROOT/package-lock.json ]; \
# then \
# npm ci --loglevel=error --no-audit; \
# else \
# npm install --loglevel=error --no-audit; \
# fi
#
#COPY . .
#
#RUN npm run build
###########################################
FROM composer:${COMPOSER_VERSION} AS vendor FROM composer:${COMPOSER_VERSION} AS vendor
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-builder-php${PHP_VERSION} AS upstream
ARG DOCKER_FILES_BASE_PATH COPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy
ARG TARGETPLATFORM
RUN CGO_ENABLED=1 \
XCADDY_SETCAP=1 \
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
CGO_CFLAGS=$(php-config --includes) \
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
xcaddy build \
--output /usr/local/bin/frankenphp \
--with github.com/dunglas/frankenphp=./ \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
--with github.com/dunglas/caddy-cbrotli
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
LABEL maintainer="solidtime <hello@solidtime.io>" LABEL maintainer="solidtime <hello@solidtime.io>"
LABEL org.opencontainers.image.title="solidtime" LABEL org.opencontainers.image.title="solidtime"
@@ -53,18 +36,22 @@ ARG WWWUSER=1000
ARG WWWGROUP=1000 ARG WWWGROUP=1000
ARG TZ=UTC ARG TZ=UTC
ARG APP_DIR=/var/www/html ARG APP_DIR=/var/www/html
ARG APP_ENV
ARG APP_HOST
ARG DOCKER_FILES_BASE_PATH
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-color \ TERM=xterm-color \
WITH_HORIZON=false \
WITH_SCHEDULER=false \
OCTANE_SERVER=frankenphp \ OCTANE_SERVER=frankenphp \
TZ=${TZ} \
USER=octane \ USER=octane \
ROOT=${APP_DIR} \ ROOT=${APP_DIR} \
APP_ENV=${APP_ENV} \
COMPOSER_FUND=0 \ COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=24 \ COMPOSER_MAX_PARALLEL_HTTP=24 \
XDG_CONFIG_HOME=${APP_DIR}/.config \ XDG_CONFIG_HOME=${APP_DIR}/.config \
XDG_DATA_HOME=${APP_DIR}/.data XDG_DATA_HOME=${APP_DIR}/.data \
SERVER_NAME=${APP_HOST}
WORKDIR ${ROOT} WORKDIR ${ROOT}
@@ -78,14 +65,16 @@ RUN apt-get update; \
apt-get install -yqq --no-install-recommends --show-progress \ apt-get install -yqq --no-install-recommends --show-progress \
apt-utils \ apt-utils \
curl \ curl \
gcc \
wget \ wget \
nano \ vim \
git \
ncdu \ ncdu \
procps \ procps \
unzip \
ca-certificates \ ca-certificates \
supervisor \ supervisor \
libsodium-dev \ libsodium-dev \
libbrotli-dev \
# Install PHP extensions (included with dunglas/frankenphp) # Install PHP extensions (included with dunglas/frankenphp)
&& install-php-extensions \ && install-php-extensions \
bz2 \ bz2 \
@@ -99,6 +88,8 @@ RUN apt-get update; \
exif \ exif \
pdo_mysql \ pdo_mysql \
zip \ zip \
uv \
vips \
intl \ intl \
gd \ gd \
redis \ redis \
@@ -128,27 +119,34 @@ RUN arch="$(uname -m)" \
RUN userdel --remove --force www-data \ RUN userdel --remove --force www-data \
&& groupadd --force -g ${WWWGROUP} ${USER} \ && groupadd --force -g ${WWWGROUP} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} && useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
&& setcap -r /usr/local/bin/frankenphp
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \ RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
&& chmod -R a+rw ${ROOT} /var/{log,run} && chmod -R a+rw ${ROOT} /var/{log,run}
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane-default.ini
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php-arm.ini ${PHP_INI_DIR}/conf.d/99-octane-arm.ini
RUN echo "TARGETPLATFORM is equal to ${TARGETPLATFORM}"
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
rm ${PHP_INI_DIR}/conf.d/99-octane-default.ini; \
else \
rm ${PHP_INI_DIR}/conf.d/99-octane-arm.ini; \
fi
USER ${USER} USER ${USER}
COPY --chown=${USER}:${USER} --from=vendor /usr/bin/composer /usr/bin/composer COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer
#COPY --chown=${USER}:${USER} composer.json composer.lock ./
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
###########################################
#FROM base AS common
#
#USER ${USER}
#
#COPY --link --chown=${WWWUSER}:${WWWUSER} . .
# #
#RUN composer install \ #RUN composer install \
# --no-dev \ # --no-dev \
@@ -158,22 +156,47 @@ COPY --chown=${USER}:${USER} --from=vendor /usr/bin/composer /usr/bin/composer
# --no-scripts \ # --no-scripts \
# --audit # --audit
COPY --chown=${USER}:${USER} . . ###########################################
#COPY --chown=${USER}:${USER} --from=build ${ROOT}/public public # Build frontend assets with Bun
###########################################
#FROM oven/bun:${BUN_VERSION} AS build
#
#ARG APP_ENV
#
#ENV ROOT=/var/www/html \
# APP_ENV=${APP_ENV} \
# NODE_ENV=${APP_ENV:-production}
#
#WORKDIR ${ROOT}
#
#COPY --link package.json bun.lock* ./
#
#RUN bun install --frozen-lockfile
#
#COPY --link . .
#COPY --link --from=common ${ROOT}/vendor vendor
#
#RUN bun run build
###########################################
#FROM common AS runner
USER ${USER}
ENV WITH_HORIZON=false \
WITH_SCHEDULER=false \
WITH_REVERB=false
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \ RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \ storage/framework/{sessions,views,cache,testing} \
storage/logs \ storage/logs \
bootstrap/cache && chmod -R a+rw storage bootstrap/cache && chmod -R a+rw storage
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/supervisor/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
# FrankenPHP embedded PHP configuration
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/php.ini
#RUN composer install \ #RUN composer install \
# --classmap-authoritative \ # --classmap-authoritative \
# --no-interaction \ # --no-interaction \
@@ -183,12 +206,9 @@ COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/ph
RUN cat .env RUN cat .env
#RUN php artisan env #RUN php artisan env
RUN php artisan storage:link
RUN chmod +x /usr/local/bin/start-container
RUN cat ${DOCKER_FILES_BASE_PATH}deployment/utilities.sh >> ~/.bashrc
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["start-container"] ENTRYPOINT ["start-container"]
#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env sh
set -e
container_mode=${CONTAINER_MODE:-"http"}
if [ "${container_mode}" = "http" ]; then
php "${ROOT}/artisan" octane:status
elif [ "${container_mode}" = "horizon" ]; then
php "${ROOT}/artisan" horizon:status
elif [ "${container_mode}" = "scheduler" ]; then
if [ "$(supervisorctl status scheduler:scheduler_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
elif [ "${container_mode}" = "reverb" ]; then
if [ "$(supervisorctl status reverb:reverb_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
elif [ "${container_mode}" = "worker" ]; then
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
else
echo "Container mode mismatched."
exit 1
fi

View File

@@ -0,0 +1,68 @@
{
{$CADDY_GLOBAL_OPTIONS}
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
frankenphp {
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
}
metrics {
per_host
}
servers {
protocols h1
}
}
{$CADDY_EXTRA_CONFIG}
{$CADDY_SERVER_SERVER_NAME} {
log {
level WARN
format filter {
wrap {$CADDY_SERVER_LOGGER}
fields {
uri query {
replace authorization REDACTED
}
}
}
}
route {
root * "{$APP_PUBLIC_PATH}"
encode zstd br gzip
{$CADDY_SERVER_EXTRA_DIRECTIVES}
request_body {
max_size 500MB
}
@static {
file
path *.js *.css *.jpg *.jpeg *.webp *.weba *.webm *.gif *.png *.ico *.cur *.gz *.svg *.svgz *.mp4 *.mp3 *.ogg *.ogv *.htc *.woff2 *.woff
}
@staticshort {
file
path *.json *.xml *.rss
}
header @static Cache-Control "public, immutable, stale-while-revalidate, max-age=31536000"
header @staticshort Cache-Control "no-cache, max-age=3600"
@rejected `path('*.bak', '*.conf', '*.dist', '*.fla', '*.ini', '*.inc', '*.inci', '*.log', '*.orig', '*.psd', '*.sh', '*.sql', '*.swo', '*.swp', '*.swop', '*/.*') && !path('*/.well-known/*')`
error @rejected 401
php_server {
index frankenphp-worker.php
try_files {path} frankenphp-worker.php
resolve_root_symlink
}
}
}

View File

@@ -1,51 +1,65 @@
[program:octane] [program:octane]
process_name=%(program_name)s_%(process_num)02d process_name = %(program_name)s_%(process_num)s
command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
; command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=localhost --port=443 --admin-port=2019 --https --http-redirect user = %(ENV_USER)s
user=%(ENV_USER)s priority = 1
autostart=true autostart = true
autorestart=true autorestart = true
environment=LARAVEL_OCTANE="1" environment = LARAVEL_OCTANE = "1"
stdout_logfile=/dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes = 0
stderr_logfile=/dev/stderr stderr_logfile = /dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes = 0
[program:horizon] [program:horizon]
process_name=%(program_name)s_%(process_num)02d process_name = %(program_name)s_%(process_num)s
command=php %(ENV_ROOT)s/artisan horizon command = php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s user = %(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s priority = 3
autorestart=true autostart = %(ENV_WITH_HORIZON)s
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log autorestart = true
stdout_logfile_maxbytes=200MB stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log stdout_logfile_maxbytes = 200MB
stderr_logfile_maxbytes=200MB stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
stopwaitsecs=3600 stderr_logfile_maxbytes = 200MB
stopwaitsecs = 3600
[program:scheduler] [program:scheduler]
process_name=%(program_name)s_%(process_num)02d process_name = %(program_name)s_%(process_num)s
command=supercronic -overlapping /etc/supercronic/laravel command = supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s user = %(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s autostart = %(ENV_WITH_SCHEDULER)s
autorestart=true autorestart = true
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB stdout_logfile_maxbytes = 200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB stderr_logfile_maxbytes = 200MB
[program:clear-scheduler-cache] [program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d process_name = %(program_name)s_%(process_num)s
command=php %(ENV_ROOT)s/artisan schedule:clear-cache command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s user = %(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s autostart = %(ENV_WITH_SCHEDULER)s
autorestart=false autorestart = false
startsecs=0 startsecs = 0
startretries=1 startretries = 1
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB stdout_logfile_maxbytes = 200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB stderr_logfile_maxbytes = 200MB
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
priority = 2
autostart = %(ENV_WITH_REVERB)s
autorestart = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
stderr_logfile_maxbytes = 200MB
minfds = 10000
[include] [include]
files=/etc/supervisor/supervisord.conf files = /etc/supervisord.conf

View File

@@ -1,25 +0,0 @@
version: '2.7'
rpc:
listen: 'tcp://127.0.0.1:6001'
server:
relay: pipes
http:
middleware: [ "static", "gzip", "headers" ]
max_request_size: 20
static:
dir: "public"
forbid: [ ".php", ".htaccess" ]
uploads:
forbid: [".php", ".exe", ".bat", ".sh"]
pool:
allocate_timeout: 10s
destroy_timeout: 10s
supervisor:
max_worker_memory: 128
exec_ttl: 60s
logs:
mode: production
level: debug
encoding: json
status:
address: localhost:2114

View File

@@ -1,50 +0,0 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8000 --rpc-port=6001 --rr-config=%(ENV_ROOT)s/.rr.yaml
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes=200MB
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
startsecs=0
startretries=1
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -1,50 +0,0 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=swoole --host=0.0.0.0 --port=8000
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes=200MB
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
startsecs=0
startretries=1
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -1,30 +0,0 @@
[PHP]
post_max_size = 100M
upload_max_filesize = 100M
expose_php = 0
realpath_cache_size = 16M
realpath_cache_ttl = 360
max_input_time = 5
[Opcache]
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 256M
opcache.use_cwd = 0
opcache.max_file_size = 0
opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0
opcache.file_update_protection = 0
opcache.interned_strings_buffer = 16
opcache.file_cache = 60
[JIT]
opcache.jit_buffer_size = 128M
opcache.jit = disable
opcache.jit_prof_threshold = 0.001
opcache.jit_max_root_traces = 2048
opcache.jit_max_side_traces = 256
[zlib]
zlib.output_compression = On
zlib.output_compression_level = 9

View File

@@ -5,6 +5,8 @@ expose_php = 0
realpath_cache_size = 16M realpath_cache_size = 16M
realpath_cache_ttl = 360 realpath_cache_ttl = 360
max_input_time = 5 max_input_time = 5
register_argc_argv = 0
date.timezone = ${TZ:-UTC}
[Opcache] [Opcache]
opcache.enable = 1 opcache.enable = 1
@@ -16,7 +18,6 @@ opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0 opcache.validate_timestamps = 0
opcache.file_update_protection = 0 opcache.file_update_protection = 0
opcache.interned_strings_buffer = 16 opcache.interned_strings_buffer = 16
opcache.file_cache = 60
[JIT] [JIT]
opcache.jit_buffer_size = 128M opcache.jit_buffer_size = 128M

View File

@@ -4,41 +4,49 @@ set -e
container_mode=${CONTAINER_MODE:-"http"} container_mode=${CONTAINER_MODE:-"http"}
octane_server=${OCTANE_SERVER} octane_server=${OCTANE_SERVER}
auto_db_migrate=${AUTO_DB_MIGRATE:-false} auto_db_migrate=${AUTO_DB_MIGRATE:-false}
echo "Container mode: $container_mode"
initialStuff() { initialStuff() {
echo "Container mode: $container_mode"
if [ ${auto_db_migrate} = "true" ]; then if [ ${auto_db_migrate} = "true" ]; then
echo "Auto database migration enabled." echo "Auto database migration enabled."
php artisan migrate --isolated --force php artisan migrate --isolated --force
fi fi
php artisan storage:link; \
php artisan optimize:clear; \ php artisan optimize:clear; \
php artisan event:cache; \ php artisan optimize;
php artisan config:cache; \
php artisan route:cache;
} }
if [ "$1" != "" ]; then if [ "$1" != "" ]; then
exec "$@" exec "$@"
elif [ ${container_mode} = "http" ]; then elif [ "${container_mode}" = "http" ]; then
echo "Octane Server: $octane_server"
initialStuff initialStuff
if [ ${octane_server} = "frankenphp" ]; then echo "Octane Server: $octane_server"
if [ "${octane_server}" = "frankenphp" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
elif [ ${octane_server} = "swoole" ]; then elif [ "${octane_server}" = "swoole" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
elif [ ${octane_server} = "roadrunner" ]; then elif [ "${octane_server}" = "roadrunner" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
else else
echo "Invalid Octane server supplied." echo "Invalid Octane server supplied."
exit 1 exit 1
fi fi
elif [ ${container_mode} = "horizon" ]; then elif [ "${container_mode}" = "horizon" ]; then
initialStuff initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.horizon.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.horizon.conf
elif [ ${container_mode} = "scheduler" ]; then elif [ "${container_mode}" = "reverb" ]; then
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.reverb.conf
elif [ "${container_mode}" = "scheduler" ]; then
initialStuff initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.scheduler.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.scheduler.conf
elif [ ${container_mode} = "worker" ]; then elif [ "${container_mode}" = "worker" ]; then
if [ -z "${WORKER_COMMAND}" ]; then
echo "WORKER_COMMAND is undefined."
exit 1
fi
initialStuff initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.worker.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.worker.conf
else else

View File

@@ -1,14 +1,13 @@
[supervisord] [supervisord]
nodaemon=true nodaemon = true
user=%(ENV_USER)s user = %(ENV_USER)s
logfile=/var/log/supervisor/supervisord.log logfile = /var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid pidfile = /var/run/supervisord.pid
[unix_http_server]
file=/var/run/supervisor.sock
[supervisorctl] [supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[inet_http_server]
port = 127.0.0.1:9001
[rpcinterface:supervisor] [rpcinterface:supervisor]
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

View File

@@ -1,14 +1,14 @@
[program:horizon] [program:horizon]
process_name=%(program_name)s_%(process_num)02d process_name = %(program_name)s_%(process_num)s
command=php %(ENV_ROOT)s/artisan horizon command = php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s user = %(ENV_USER)s
autostart=true autostart = true
autorestart=true autorestart = true
stdout_logfile=/dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes = 0
stderr_logfile=/dev/stderr stderr_logfile = /dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes = 0
stopwaitsecs=3600 stopwaitsecs = 3600
[include] [include]
files=/etc/supervisor/supervisord.conf files = /etc/supervisord.conf

View File

@@ -0,0 +1,14 @@
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
minfds = 10000
[include]
files = /etc/supervisord.conf

View File

@@ -1,26 +1,26 @@
[program:scheduler] [program:scheduler]
process_name=%(program_name)s_%(process_num)02d process_name = %(program_name)s_%(process_num)s
command=supercronic -overlapping /etc/supercronic/laravel command = supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s user = %(ENV_USER)s
autostart=true autostart = true
autorestart=true autorestart = true
stdout_logfile=/dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes = 0
stderr_logfile=/dev/stderr stderr_logfile = /dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes = 0
[program:clear-scheduler-cache] [program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d process_name = %(program_name)s_%(process_num)s
command=php %(ENV_ROOT)s/artisan schedule:clear-cache command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s user = %(ENV_USER)s
autostart=true autostart = true
autorestart=false autorestart = false
startsecs=0 startsecs = 0
startretries=1 startretries = 1
stdout_logfile=/dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes = 0
stderr_logfile=/dev/stderr stderr_logfile = /dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes = 0
[include] [include]
files=/etc/supervisor/supervisord.conf files = /etc/supervisord.conf

View File

@@ -1,42 +0,0 @@
[supervisord]
nodaemon=true
user=%(ENV_USER)s
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=swoole --host=0.0.0.0 --port=8000
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/horizon.log
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/scheduler.log
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
stdout_logfile=%(ENV_ROOT)s/scheduler.log

View File

@@ -1,13 +1,13 @@
[program:worker] [program:worker]
process_name=%(program_name)s_%(process_num)02d process_name = %(program_name)s_%(process_num)s
command=%(ENV_WORKER_COMMAND)s command = %(ENV_WORKER_COMMAND)s
user=%(ENV_USER)s user = %(ENV_USER)s
autostart=true autostart = true
autorestart=true autorestart = true
stdout_logfile=/dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes = 0
stderr_logfile=/dev/stderr stderr_logfile = /dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes = 0
[include] [include]
files=/etc/supervisor/supervisord.conf files = /etc/supervisord.conf

Some files were not shown because too many files have changed in this diff Show More