Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
9cb3aea7be Add checks for placeholder invitation; Fixed bug in member deletion 2025-07-07 16:54:26 +02:00
193 changed files with 2261 additions and 8081 deletions

View File

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

View File

@@ -6,6 +6,7 @@ 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;
@@ -58,7 +59,8 @@ 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 [ return array_filter([
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
@@ -75,7 +75,7 @@ class AddOrganizationMember implements AddsTeamMembers
Role::Employee->value, Role::Employee->value,
]), ]),
], ],
]; ]);
} }
/** /**

View File

@@ -1,108 +0,0 @@
<?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,10 +18,6 @@ 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();

View File

@@ -1,16 +0,0 @@
<?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

@@ -41,7 +41,9 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
return null; return null;
} }
$collectingType = $this->openApiTransformer->transform($collectingClassType); if (! ($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,7 +15,6 @@ 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;
@@ -76,8 +75,7 @@ class FailedJobResource extends Resource
->filters([]) ->filters([])
->bulkActions([ ->bulkActions([
BulkAction::make('retry') BulkAction::make('retry')
->icon('heroicon-o-arrow-path') ->label('Retry')
->label('Retry selected')
->requiresConfirmation() ->requiresConfirmation()
->action(function (Collection $records): void { ->action(function (Collection $records): void {
/** @var FailedJob $record */ /** @var FailedJob $record */
@@ -89,13 +87,11 @@ class FailedJobResource extends Resource
->success() ->success()
->send(); ->send();
}), }),
DeleteBulkAction::make(),
]) ])
->actions([ ->actions([
DeleteAction::make(), DeleteAction::make('Delete'),
ViewAction::make(), ViewAction::make('View'),
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 {
@@ -113,6 +109,7 @@ 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,8 +19,7 @@ class ListFailedJobs extends ListRecords
{ {
return [ return [
Action::make('retry_all') Action::make('retry_all')
->icon('heroicon-o-arrow-path') ->label('Retry all failed Jobs')
->label('Retry all')
->requiresConfirmation() ->requiresConfirmation()
->action(function (): void { ->action(function (): void {
Artisan::call('queue:retry all'); Artisan::call('queue:retry all');
@@ -31,8 +30,7 @@ class ListFailedJobs extends ListRecords
}), }),
Action::make('delete_all') Action::make('delete_all')
->icon('heroicon-o-trash') ->label('Delete all failed Jobs')
->label('Delete all')
->requiresConfirmation() ->requiresConfirmation()
->color('danger') ->color('danger')
->action(function (): void { ->action(function (): void {

View File

@@ -5,6 +5,7 @@ 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;
@@ -39,7 +40,7 @@ class TokenResource extends Resource
->label('Name') ->label('Name')
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('owner_id') Forms\Components\Select::make('user_id')
->label('User') ->label('User')
->relationship(name: 'user', titleAttribute: 'name') ->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name']) ->searchable(['name'])
@@ -78,12 +79,10 @@ class TokenResource extends Resource
Tables\Columns\TextColumn::make('client.name') Tables\Columns\TextColumn::make('client.name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\IconColumn::make('personal_access_client') Tables\Columns\IconColumn::make('client.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?')
@@ -105,11 +104,17 @@ class TokenResource extends Resource
->queries( ->queries(
true: function (Builder $query) { true: function (Builder $query) {
/** @var Builder<Token> $query */ /** @var Builder<Token> $query */
return $query->isApiToken(); return $query->whereHas('client', function (Builder $query) {
/** @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->isApiToken(false); return $query->whereHas('client', function (Builder $query) {
/** @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,12 +8,9 @@ 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
{ {
@@ -31,10 +28,7 @@ class ApiTokenController extends Controller
$user = $this->user(); $user = $this->user();
$tokens = $user->tokens() $tokens = $user->tokens()
->whereHas('client', function (Builder $query): void { ->where('client_id', '=', config('passport.personal_access_client.id'))
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
})
->get(); ->get();
return new ApiTokenCollection($tokens); return new ApiTokenCollection($tokens);
@@ -54,21 +48,15 @@ class ApiTokenController extends Controller
{ {
$user = $this->user(); $user = $this->user();
try { if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
$token = $user->createToken($request->getName(), ['*']); throw new PersonalAccessClientIsNotConfiguredException;
/** @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);
} }
/** /**
@@ -83,10 +71,13 @@ 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?->hasGrantType('personal_access') ?? false)) { if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token'); throw new AuthorizationException('API token is not a personal access token');
} }
@@ -106,10 +97,13 @@ 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?->hasGrantType('personal_access') ?? false)) { if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token'); throw new AuthorizationException('API token is not a personal access token');
} }

View File

@@ -73,9 +73,7 @@ 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(),
@@ -86,9 +84,7 @@ 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,8 +107,6 @@ 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,7 +33,6 @@ 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;
@@ -48,7 +47,6 @@ 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;
@@ -142,15 +140,8 @@ 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);
@@ -192,8 +183,6 @@ 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([
@@ -218,9 +207,8 @@ 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(
$timeEntriesAggregateQuery, $timeEntriesQuery->clone()->reorder()->withOnly([]),
null, null,
null, null,
$user->timezone, $user->timezone,
@@ -228,9 +216,7 @@ 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(),
@@ -338,8 +324,6 @@ 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,
@@ -350,9 +334,7 @@ class TimeEntryController extends Controller
$request->getFillGapsInTimeGroups(), $request->getFillGapsInTimeGroups(),
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd(),
$showBillableRate, $showBillableRate
$roundingType,
$roundingMinutes
); );
return [ return [
@@ -391,8 +373,6 @@ 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(),
@@ -403,9 +383,7 @@ 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(),
@@ -416,9 +394,7 @@ 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());
@@ -501,7 +477,7 @@ class TimeEntryController extends Controller
/** /**
* @return Builder<TimeEntry> * @return Builder<TimeEntry>
*/ */
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
{ {
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization'); ->whereBelongsTo($organization, 'organization');

View File

@@ -5,6 +5,7 @@ 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;
@@ -19,7 +20,8 @@ class EnsureEmailIsVerified
{ {
if (! app()->isLocal()) { if (! app()->isLocal()) {
if ($request->user() === null || if ($request->user() === null ||
(! $request->user()->hasVerifiedEmail())) { ($request->user() instanceof MustVerifyEmail &&
! $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' => $currentOrganization !== null ? [ 'billing' => $billing !== null && $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([ Inertia::share(array_filter([
'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

@@ -6,7 +6,6 @@ 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;
@@ -129,18 +128,6 @@ 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',
],
]; ];
} }
@@ -218,22 +205,4 @@ 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,7 +7,6 @@ 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;
@@ -165,18 +164,6 @@ 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',
],
]; ];
} }
@@ -224,22 +211,4 @@ 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,7 +5,6 @@ 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;
@@ -147,18 +146,6 @@ 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',
],
]; ];
} }
@@ -186,22 +173,4 @@ 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,7 +5,6 @@ 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;
@@ -134,18 +133,6 @@ 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',
],
]; ];
} }
@@ -183,22 +170,4 @@ 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,7 +4,6 @@ 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;
@@ -12,10 +11,8 @@ 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;
/** /**
@@ -26,7 +23,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|RuleContract>> * @return array<string, array<string|ValidationRule>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -139,18 +136,6 @@ 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',
],
]; ];
} }
@@ -168,22 +153,4 @@ 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,11 +8,15 @@ 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,10 +58,6 @@ 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

@@ -1,44 +0,0 @@
<?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

@@ -1,44 +0,0 @@
<?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<string, mixed>|null $old_values * @property array|null $old_values
* @property array<string, mixed>|null $new_values * @property array|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, $this> * @return BelongsTo<Organization, Client>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -55,7 +55,7 @@ class Client extends Model implements AuditableContract
} }
/** /**
* @return HasMany<Project, $this> * @return HasMany<Project>
*/ */
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<int, ProjectMember> $projectMembers * @property-read Collection<ProjectMember> $projectMembers
* @property-read Collection<int, TimeEntry> $timeEntries * @property-read Collection<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, $this> * @return BelongsTo<User, Member>
*/ */
public function user(): BelongsTo public function user(): BelongsTo
{ {
@@ -55,7 +55,7 @@ class Member extends JetstreamMembership implements AuditableContract
} }
/** /**
* @return BelongsTo<Organization, $this> * @return BelongsTo<Organization, Member>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -63,7 +63,7 @@ class Member extends JetstreamMembership implements AuditableContract
} }
/** /**
* @return HasMany<TimeEntry, $this> * @return HasMany<TimeEntry>
*/ */
public function timeEntries(): HasMany public function timeEntries(): HasMany
{ {
@@ -71,7 +71,7 @@ class Member extends JetstreamMembership implements AuditableContract
} }
/** /**
* @return HasMany<ProjectMember, $this> * @return HasMany<ProjectMember>
*/ */
public function projectMembers(): HasMany public function projectMembers(): HasMany
{ {

View File

@@ -18,7 +18,6 @@ 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;
@@ -48,7 +47,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, $this> teamInvitations() * @method HasMany<OrganizationInvitation> teamInvitations()
* @method static OrganizationFactory factory() * @method static OrganizationFactory factory()
*/ */
class Organization extends JetstreamTeam implements AuditableContract class Organization extends JetstreamTeam implements AuditableContract
@@ -80,7 +79,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
* @var list<string> * @var array<int, string>
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name',
@@ -126,7 +125,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, $this, Pivot, 'membership'> * @return BelongsToMany<User>
*/ */
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
@@ -143,7 +142,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/** /**
* Get the owner of the team. * Get the owner of the team.
* *
* @return BelongsTo<User, $this> * @return BelongsTo<User, Organization>
*/ */
public function owner(): BelongsTo public function owner(): BelongsTo
{ {
@@ -151,7 +150,7 @@ class Organization extends JetstreamTeam implements AuditableContract
} }
/** /**
* @return HasMany<Member, $this> * @return HasMany<Member>
*/ */
public function members(): HasMany public function members(): HasMany
{ {
@@ -159,7 +158,7 @@ class Organization extends JetstreamTeam implements AuditableContract
} }
/** /**
* @return BelongsToMany<User, $this, Pivot, 'membership'> * @return BelongsToMany<User>
*/ */
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, $this> * @return BelongsTo<Organization, OrganizationInvitation>
*/ */
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, $this> * @return BelongsTo<Organization, OrganizationInvitation>
*/ */
public function team(): BelongsTo public function team(): BelongsTo
{ {

View File

@@ -4,26 +4,6 @@ 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,36 +5,22 @@ 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 $owner_id * @property string|null $user_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 array<string> $grant_types * @property string $redirect
* @property array<string> $redirect_uris * @property bool $personal_access_client
* @property Carbon|null $created_at * @property bool $password_client
* @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

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

View File

@@ -4,9 +4,7 @@ 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;
@@ -19,15 +17,9 @@ 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
{ {
@@ -37,60 +29,10 @@ class Token extends PassportToken
/** /**
* Get the client that the token belongs to. * Get the client that the token belongs to.
* *
* @return BelongsTo<Client, $this> * @return BelongsTo<Client, Token>
*/ */
// @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, $this> * @return BelongsTo<Organization, Project>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -145,7 +145,7 @@ class Project extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Client, $this> * @return BelongsTo<Client, Project>
*/ */
public function client(): BelongsTo public function client(): BelongsTo
{ {
@@ -153,7 +153,7 @@ class Project extends Model implements AuditableContract
} }
/** /**
* @return HasMany<ProjectMember, $this> * @return HasMany<ProjectMember>
*/ */
public function members(): HasMany public function members(): HasMany
{ {
@@ -161,7 +161,7 @@ class Project extends Model implements AuditableContract
} }
/** /**
* @return HasMany<Task, $this> * @return HasMany<Task>
*/ */
public function tasks(): HasMany public function tasks(): HasMany
{ {
@@ -169,7 +169,7 @@ class Project extends Model implements AuditableContract
} }
/** /**
* @return HasMany<TimeEntry, $this> * @return HasMany<TimeEntry>
*/ */
public function timeEntries(): HasMany public function timeEntries(): HasMany
{ {

View File

@@ -48,7 +48,7 @@ class ProjectMember extends Model implements AuditableContract
]; ];
/** /**
* @return BelongsTo<Project, $this> * @return BelongsTo<Project, ProjectMember>
*/ */
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, $this> * @return BelongsTo<User, ProjectMember>
*/ */
public function user(): BelongsTo public function user(): BelongsTo
{ {
@@ -66,7 +66,7 @@ class ProjectMember extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Member, $this> * @return BelongsTo<Member, ProjectMember>
*/ */
public function member(): BelongsTo public function member(): BelongsTo
{ {

View File

@@ -55,7 +55,7 @@ class Report extends Model
} }
/** /**
* @return BelongsTo<Organization, $this> * @return BelongsTo<Organization, Report>
*/ */
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<int, TimeEntry> $timeEntries * @property-read Collection<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, $this> * @return BelongsTo<Organization, Tag>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {

View File

@@ -120,7 +120,7 @@ class Task extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Project, $this> * @return BelongsTo<Project, Task>
*/ */
public function project(): BelongsTo public function project(): BelongsTo
{ {
@@ -128,7 +128,7 @@ class Task extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Organization, $this> * @return BelongsTo<Organization, Task>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -136,7 +136,7 @@ class Task extends Model implements AuditableContract
} }
/** /**
* @return HasMany<TimeEntry, $this> * @return HasMany<TimeEntry>
*/ */
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<string> $tags * @property array $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<int, Tag> $tagsRelation * @property-read Collection<Tag> $tagsRelation
* *
* @method Builder<TimeEntry> hasTag(Tag $tag) * @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory() * @method static TimeEntryFactory factory()
@@ -77,26 +77,6 @@ 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.
@@ -174,7 +154,7 @@ class TimeEntry extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<User, $this> * @return BelongsTo<User, TimeEntry>
*/ */
public function user(): BelongsTo public function user(): BelongsTo
{ {
@@ -182,7 +162,7 @@ class TimeEntry extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Member, $this> * @return BelongsTo<Member, TimeEntry>
*/ */
public function member(): BelongsTo public function member(): BelongsTo
{ {
@@ -190,7 +170,7 @@ class TimeEntry extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Organization, $this> * @return BelongsTo<Organization, TimeEntry>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -198,7 +178,7 @@ class TimeEntry extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Project, $this> * @return BelongsTo<Project, TimeEntry>
*/ */
public function project(): BelongsTo public function project(): BelongsTo
{ {
@@ -206,7 +186,7 @@ class TimeEntry extends Model implements AuditableContract
} }
/** /**
* @return BelongsTo<Task, $this> * @return BelongsTo<Task, TimeEntry>
*/ */
public function task(): BelongsTo public function task(): BelongsTo
{ {
@@ -216,7 +196,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, $this> * @return BelongsTo<Client, TimeEntry>
*/ */
public function client(): BelongsTo public function client(): BelongsTo
{ {

View File

@@ -19,7 +19,6 @@ 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;
@@ -28,7 +27,6 @@ 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;
@@ -54,13 +52,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, $this> ownedTeams() * @method HasMany<Organization> 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, OAuthenticatable class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail
{ {
use CustomAuditable; use CustomAuditable;
use HasApiTokens; use HasApiTokens;
@@ -77,7 +75,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
* @var list<string> * @var array<int, string>
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name',
@@ -88,7 +86,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 list<string> * @var array<int, string>
*/ */
protected $hidden = [ protected $hidden = [
'password', 'password',
@@ -145,7 +143,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return BelongsToMany<Organization, $this, Pivot, 'membership'> * @return BelongsToMany<Organization>
*/ */
public function organizations(): BelongsToMany public function organizations(): BelongsToMany
{ {
@@ -160,7 +158,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return HasMany<TimeEntry, $this> * @return HasMany<TimeEntry>
*/ */
public function timeEntries(): HasMany public function timeEntries(): HasMany
{ {
@@ -168,7 +166,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return BelongsTo<Organization, $this> * @return BelongsTo<Organization, User>
*/ */
public function currentOrganization(): BelongsTo public function currentOrganization(): BelongsTo
{ {
@@ -176,7 +174,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return HasMany<ProjectMember, $this> * @return HasMany<ProjectMember>
*/ */
public function projectMembers(): HasMany public function projectMembers(): HasMany
{ {
@@ -184,7 +182,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return HasMany<Token, $this> * @return HasMany<Token>
*/ */
public function accessTokens(): HasMany public function accessTokens(): HasMany
{ {
@@ -192,13 +190,24 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
} }
/** /**
* @return HasMany<AuthCode, $this> * @return HasMany<AuthCode>
*/ */
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,6 +7,7 @@ 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;
@@ -50,8 +51,7 @@ 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

@@ -4,7 +4,6 @@ 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
@@ -375,12 +374,4 @@ 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,7 +6,6 @@ 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;
@@ -60,10 +59,6 @@ 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.
* *
@@ -120,14 +115,13 @@ 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)) {
@@ -149,8 +143,6 @@ 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

@@ -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,7 +6,6 @@ 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;
@@ -42,7 +41,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, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
{ {
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null; $fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
$group1Select = null; $group1Select = null;
@@ -57,14 +56,15 @@ 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 ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. ' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost' ' round(
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, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
{ {
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes); $aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
$keysGroup1 = []; $keysGroup1 = [];
$keysGroup2 = []; $keysGroup2 = [];

View File

@@ -1,42 +0,0 @@
<?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.4.0", "flowframe/laravel-trend": "^0.3.0",
"gotenberg/gotenberg-php": "^2.8", "gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^2.0.3", "inertiajs/inertia-laravel": "^1.0",
"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": "^12.19.3", "laravel/framework": "^11.16.0",
"laravel/jetstream": "^5.0", "laravel/jetstream": "^5.0",
"laravel/octane": "^2.3", "laravel/octane": "^2.3",
"laravel/passport": "^13.0.5", "laravel/passport": "^12.0",
"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": "^12.0.4", "nwidart/laravel-modules": "^11.0.11",
"owen-it/laravel-auditing": "^14.0.0", "owen-it/laravel-auditing": "^13.6",
"pxlrbt/filament-environment-indicator": "^2.1.0", "pxlrbt/filament-environment-indicator": "^2.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": "^3.0.0", "tpetry/laravel-postgresql-enhanced": "^2.0.0",
"wikimedia/composer-merge-plugin": "^2.1.0" "wikimedia/composer-merge-plugin": "^2.1.0"
}, },
"require-dev": { "require-dev": {
@@ -43,13 +43,14 @@
"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",
"larastan/larastan": "^3.5.0", "phpstan/phpstan": "1.12.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": "^12", "phpunit/phpunit": "^11",
"spatie/laravel-ignition": "^2.0", "spatie/laravel-ignition": "^2.0",
"timacdonald/log-fake": "^2.1" "timacdonald/log-fake": "^2.1"
}, },

2470
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,15 +34,31 @@ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Passport Database Connection | Client UUIDs
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| By default, Passport's models will utilize your application's default | By default, Passport uses auto-incrementing primary keys when assigning
| database connection. If you wish to use a different connection you | IDs to clients. However, if Passport is installed using the provided
| may specify the configured name of the database connection here. | --uuids switch, this will be set to "true" and UUIDs will be used.
| |
*/ */
'connection' => env('PASSPORT_CONNECTION'), 'client_uuids' => true,
/*
|--------------------------------------------------------------------------
| 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,7 +6,6 @@ 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,7 +11,6 @@ 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;
/** /**
@@ -28,7 +27,7 @@ class OrganizationFactory extends Factory
{ {
return [ return [
'name' => $this->faker->unique()->company(), 'name' => $this->faker->unique()->company(),
'currency' => app(CurrencyService::class)->getRandomCurrencyCode(), 'currency' => $this->faker->currencyCode(),
'billable_rate' => null, 'billable_rate' => null,
'user_id' => User::factory(), 'user_id' => User::factory(),
'personal_team' => true, 'personal_team' => true,

View File

@@ -7,12 +7,11 @@ 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 BaseClientFactory class ClientFactory extends Factory
{ {
/** /**
* Define the model's default state. * Define the model's default state.
@@ -23,40 +22,24 @@ class ClientFactory extends BaseClientFactory
{ {
return [ return [
'id' => $this->faker->uuid, 'id' => $this->faker->uuid,
'owner_id' => null, 'user_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_uris' => [$this->faker->url()], 'redirect' => $this->faker->url(),
'grant_types' => [], 'personal_access_client' => false,
'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 [
'grant_types' => ['personal_access'], 'personal_access_client' => true,
]; ];
}); });
} }
@@ -65,8 +48,7 @@ class ClientFactory extends BaseClientFactory
{ {
return $this->state(function (array $attributes) use ($user): array { return $this->state(function (array $attributes) use ($user): array {
return [ return [
'owner_id' => $user->getKey(), 'user_id' => $user->getKey(),
'owner_type' => (new User)->getMorphClass(),
]; ];
}); });
} }

View File

@@ -31,8 +31,6 @@ 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,16 +153,6 @@ 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

@@ -1,44 +0,0 @@
<?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

@@ -1,35 +0,0 @@
<?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

@@ -1,115 +0,0 @@
<?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

@@ -1,35 +0,0 @@
<?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

@@ -1,32 +0,0 @@
<?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,6 +24,7 @@ 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;
@@ -36,18 +37,6 @@ 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',
@@ -57,16 +46,17 @@ 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_uris = ['http://localhost']; $personalAccessClient->redirect = 'http://localhost';
$personalAccessClient->user_id = null;
$personalAccessClient->revoked = false; $personalAccessClient->revoked = false;
$personalAccessClient->provider = 'users'; $personalAccessClient->provider = null;
$personalAccessClient->grant_types = ['personal_access']; $personalAccessClient->personal_access_client = true;
$personalAccessClient->password_client = false;
$personalAccessClient->save(); $personalAccessClient->save();
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([ $userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
@@ -207,6 +197,7 @@ 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,30 +1,47 @@
# Accepted values: 8.3 - 8.2
ARG PHP_VERSION=8.3 ARG PHP_VERSION=8.3
ARG FRANKENPHP_VERSION=1.8
ARG COMPOSER_VERSION=2.8 ARG FRANKENPHP_VERSION=latest
ARG BUN_VERSION="latest"
ARG APP_ENV ARG COMPOSER_VERSION=latest
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}-builder-php${PHP_VERSION} AS upstream FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
COPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy ARG DOCKER_FILES_BASE_PATH
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"
@@ -36,22 +53,18 @@ 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}
@@ -65,16 +78,14 @@ 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 \
vim \ nano \
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 \
@@ -88,8 +99,6 @@ RUN apt-get update; \
exif \ exif \
pdo_mysql \ pdo_mysql \
zip \ zip \
uv \
vips \
intl \ intl \
gd \ gd \
redis \ redis \
@@ -119,34 +128,27 @@ 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 --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer COPY --chown=${USER}:${USER} --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 \
@@ -156,47 +158,22 @@ RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
# --no-scripts \ # --no-scripts \
# --audit # --audit
########################################### COPY --chown=${USER}:${USER} . .
# Build frontend assets with Bun #COPY --chown=${USER}:${USER} --from=build ${ROOT}/public public
###########################################
#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 \
@@ -206,9 +183,12 @@ RUN mkdir -p \
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

@@ -1,35 +0,0 @@
#!/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

@@ -1,68 +0,0 @@
{
{$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,65 +1,51 @@
[program:octane] [program:octane]
process_name = %(program_name)s_%(process_num)s process_name=%(program_name)s_%(process_num)02d
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=0.0.0.0 --port=8000 --admin-port=2019
user = %(ENV_USER)s ; command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=localhost --port=443 --admin-port=2019 --https --http-redirect
priority = 1 user=%(ENV_USER)s
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)s process_name=%(program_name)s_%(process_num)02d
command = php %(ENV_ROOT)s/artisan horizon command=php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s user=%(ENV_USER)s
priority = 3 autostart=%(ENV_WITH_HORIZON)s
autostart = %(ENV_WITH_HORIZON)s autorestart=true
autorestart = true stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log stdout_logfile_maxbytes=200MB
stdout_logfile_maxbytes = 200MB stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log stderr_logfile_maxbytes=200MB
stderr_logfile_maxbytes = 200MB stopwaitsecs=3600
stopwaitsecs = 3600
[program:scheduler] [program:scheduler]
process_name = %(program_name)s_%(process_num)s process_name=%(program_name)s_%(process_num)02d
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)s process_name=%(program_name)s_%(process_num)02d
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/supervisord.conf files=/etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,50 @@
[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

@@ -0,0 +1,50 @@
[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

@@ -0,0 +1,30 @@
[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,8 +5,6 @@ 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
@@ -18,6 +16,7 @@ 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,49 +4,41 @@ 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 optimize; php artisan event:cache; \
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
initialStuff
echo "Octane Server: $octane_server" echo "Octane Server: $octane_server"
if [ "${octane_server}" = "frankenphp" ]; then initialStuff
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}" = "reverb" ]; then elif [ ${container_mode} = "scheduler" ]; 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,13 +1,14 @@
[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)s process_name=%(program_name)s_%(process_num)02d
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/supervisord.conf files=/etc/supervisor/supervisord.conf

View File

@@ -1,14 +0,0 @@
[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)s process_name=%(program_name)s_%(process_num)02d
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)s process_name=%(program_name)s_%(process_num)02d
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/supervisord.conf files=/etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,42 @@
[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)s process_name=%(program_name)s_%(process_num)02d
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/supervisord.conf files=/etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,12 @@
tinker() {
if [ -z "$1" ]; then
php artisan tinker
else
php artisan tinker --execute="\"dd($1);\""
fi
}
# Commonly used aliases
alias ..="cd .."
alias ...="cd ../.."
alias art="php artisan"

View File

@@ -31,7 +31,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
await page.getByRole('button', { name: 'Manual time entry' }).click(); await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry for ${projectName}`); await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click(); await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click(); await page.getByText(projectName).click();
@@ -52,7 +52,7 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
await page.getByRole('button', { name: 'Manual time entry' }).click(); await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry with tag ${tagName}`); await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
// Add tag // Add tag
await page.getByRole('button', { name: 'Tags' }).click(); await page.getByRole('button', { name: 'Tags' }).click();
@@ -74,7 +74,7 @@ async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean
await page.getByRole('button', { name: 'Manual time entry' }).click(); await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`); await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
// Set billable status // Set billable status
await page.getByRole('button', { name: 'Non-Billable' }).click(); await page.getByRole('button', { name: 'Non-Billable' }).click();
@@ -103,7 +103,7 @@ test('test that project filtering works in reporting', async ({ page }) => {
// Go to reporting and filter by project1 // Go to reporting and filter by project1
await goToReporting(page); await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click(); await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByRole('dialog').getByText(project1).click(); await page.getByText(project1).click();
await Promise.all([ await Promise.all([
// escape // escape

2
package-lock.json generated
View File

@@ -16,7 +16,7 @@
"@tanstack/vue-table": "^8.21.2", "@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.5.0",
"@vueuse/integrations": "^12.5.0", "@vueuse/integrations": "^12.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -46,7 +46,7 @@
"@tanstack/vue-table": "^8.21.2", "@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.5.0",
"@vueuse/integrations": "^12.5.0", "@vueuse/integrations": "^12.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -12,7 +12,3 @@ parameters:
checkOctaneCompatibility: true checkOctaneCompatibility: true
checkModelProperties: true checkModelProperties: true
noEnvCallsOutsideOfConfig: true noEnvCallsOutsideOfConfig: true
ignoreErrors:
- '# is not subtype of native type Illuminate\\Database\\Eloquent\\Builder#'
- '# is not subtype of native type Illuminate\\Database\\Eloquent\\Relations\\Relation#'

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -160,17 +160,30 @@ body {
} }
/* Inter Variable Font with browser compatibility considerations */
@font-face { @font-face {
font-family: 'Inter'; font-family: 'Outfit';
src: url('/fonts/Inter-Variable.woff2') format('woff2 supports variations'), src: url('/fonts/Outfit-Regular.ttf');
url('/fonts/Inter-Variable.woff2') format('woff2-variations'), font-weight: 400;
url('/fonts/Inter-Variable.ttf') format('truetype supports variations'), }
url('/fonts/Inter-Variable.ttf') format('truetype-variations'); @font-face {
font-weight: 100 900; font-family: 'Outfit';
font-style: normal; src: url('/fonts/Outfit-Medium.ttf');
font-display: swap; font-weight: 500;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; }
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-SemiBold.ttf');
font-weight: 600;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-Bold.ttf');
font-weight: 700;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-ExtraBold.ttf');
font-weight: 800;
} }
@layer base { @layer base {
@@ -192,7 +205,7 @@ body {
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--color-text-primary); --destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary); --border: var(--color-border-primary);
--input: var(--color-border-tertiary); --input: var(--theme-color-input-background);
--ring: var(--theme-color-ring); --ring: var(--theme-color-ring);
--chart-1: var(--color-accent-400); --chart-1: var(--color-accent-400);
--chart-2: var(--color-accent-500); --chart-2: var(--color-accent-500);
@@ -219,7 +232,7 @@ body {
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary); --destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary); --border: var(--color-border-primary);
--input: var(--color-border-tertiary); --input: var(--theme-color-input-background);
--ring: var(--theme-color-ring); --ring: var(--theme-color-ring);
--chart-1: var(--color-accent-200); --chart-1: var(--color-accent-200);
--chart-2: var(--color-accent-300); --chart-2: var(--color-accent-300);

View File

@@ -127,7 +127,7 @@ const option = computed(() => ({
fontWeight: 600, fontWeight: 600,
color: labelColor.value, color: labelColor.value,
margin: 16, margin: 16,
fontFamily: 'Inter, sans-serif', fontFamily: 'Outfit, sans-serif',
}, },
axisTick: { axisTick: {
lineStyle: { lineStyle: {
@@ -139,7 +139,7 @@ const option = computed(() => ({
type: 'value', type: 'value',
axisLabel: { axisLabel: {
color: labelColor.value, color: labelColor.value,
fontFamily: 'Inter, sans-serif', fontFamily: 'Outfit, sans-serif',
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button } from '@/Components/ui/button'; import Badge from '@/packages/ui/src/Badge.vue';
const props = defineProps<{ const props = defineProps<{
icon: Component; icon: Component;
@@ -12,39 +12,32 @@ import { twMerge } from 'tailwind-merge';
const activeClass = computed(() => { const activeClass = computed(() => {
if (props.active) { if (props.active) {
return 'border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10'; return 'border-accent-300/50 bg-accent-300/10 hover:bg-accent-300/20';
} }
return ''; return '';
}); });
const iconClass = computed(() => {
return twMerge(
'-ml-0.5 h-4 w-4',
props.active ? 'dark:text-accent-300/80 text-accent-400/80' : 'text-text-quaternary'
);
});
</script> </script>
<template> <template>
<Button <Badge
variant="outline" size="large"
size="sm" tag="button"
:class=" :class="
twMerge( twMerge(
'cursor-pointer bg-input-background hover:bg-card-background transition flex',
activeClass activeClass
) )
"> ">
<component <component
:is="icon" :is="icon"
:class="iconClass" class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
></component>
<span class="text-nowrap"> {{ title }} </span> <span class="text-nowrap"> {{ title }} </span>
<div <div
v-if="count" v-if="count"
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center"> class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
{{ count }} {{ count }}
</div> </div>
</Button> </Badge>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -16,7 +16,6 @@ import {
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue'; import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue'; import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue'; import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
@@ -34,7 +33,7 @@ import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { computed, type ComputedRef, inject, onMounted, ref, watch } from 'vue'; import { computed, type ComputedRef, inject, onMounted, ref } from 'vue';
import { type GroupingOption, useReportingStore } from '@/utils/useReporting'; import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { import {
@@ -55,9 +54,6 @@ import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color'; import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
const { handleApiRequestNotifications } = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
@@ -75,9 +71,6 @@ const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]); const selectedClients = ref<string[]>([]);
const billable = ref<'true' | 'false' | null>(null); const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
const roundingMinutes = ref<number>(15);
const group = useStorage<GroupingOption>('reporting-group', 'project'); const group = useStorage<GroupingOption>('reporting-group', 'project');
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task'); const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
@@ -91,11 +84,6 @@ const { groupByOptions } = reportingStore;
const organization = inject<ComputedRef<Organization>>('organization'); const organization = inject<ComputedRef<Organization>>('organization');
// Watch rounding enabled state to trigger updates
watch(roundingEnabled, () => {
updateReporting();
});
function getFilterAttributes(): AggregatedTimeEntriesQueryParams { function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
let params: AggregatedTimeEntriesQueryParams = { let params: AggregatedTimeEntriesQueryParams = {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(), start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
@@ -123,8 +111,6 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
getCurrentRole() === 'employee' getCurrentRole() === 'employee'
? getCurrentMembershipId() ? getCurrentMembershipId()
: undefined, : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
}; };
return params; return params;
} }
@@ -319,7 +305,7 @@ const tableData = computed(() => {
<div class="py-2.5 w-full border-b border-default-background-separator"> <div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between"> <MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div <div
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3"> class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-4">
<div class="text-sm font-medium">Filters</div> <div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown <MemberMultiselectDropdown
v-model="selectedMembers" v-model="selectedMembers"
@@ -409,11 +395,6 @@ const tableData = computed(() => {
:icon="BillableIcon"></ReportingFilterBadge> :icon="BillableIcon"></ReportingFilterBadge>
</template> </template>
</SelectDropdown> </SelectDropdown>
<ReportingRoundingControls
v-model:enabled="roundingEnabled"
v-model:type="roundingType"
v-model:minutes="roundingMinutes"
@change="updateReporting"></ReportingRoundingControls>
</div> </div>
<div> <div>
<DateRangePicker <DateRangePicker
@@ -509,7 +490,7 @@ const tableData = computed(() => {
<div <div
v-else v-else
class="chart flex flex-col items-center justify-center py-12 col-span-3"> class="chart flex flex-col items-center justify-center py-12 col-span-3">
<p class="text-lg text-text-primary font-medium"> <p class="text-lg text-text-primary font-semibold">
No time entries found No time entries found
</p> </p>
<p>Try to change the filters and time range</p> <p>Try to change the filters and time range</p>

View File

@@ -1,225 +0,0 @@
<script setup lang="ts">
import { Switch } from '@/Components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/Components/ui/select';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import {
NumberField,
NumberFieldInput,
NumberFieldContent,
NumberFieldIncrement,
NumberFieldDecrement
} from '@/Components/ui/number-field';
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
import { computed, ref, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
// TimeEntryRoundingType definition
const TimeEntryRoundingType = {
Up: 'up' as const,
Down: 'down' as const,
Nearest: 'nearest' as const,
} as const;
type TimeEntryRoundingType = typeof TimeEntryRoundingType[keyof typeof TimeEntryRoundingType];
interface Props {
enabled: boolean;
type: TimeEntryRoundingType;
minutes: number;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:enabled': [value: boolean];
'update:type': [value: TimeEntryRoundingType];
'update:minutes': [value: number];
'change': [];
}>();
function updateEnabled(value: boolean) {
emit('update:enabled', value);
emit('change');
}
function updateType(value: TimeEntryRoundingType) {
emit('update:type', value);
emit('change');
}
function updateMinutes(value: number) {
emit('update:minutes', value);
emit('change');
}
// Predefined intervals
const predefinedIntervals = [
{ value: '5', label: '5 minutes' },
{ value: '6', label: '6 minutes' },
{ value: '10', label: '10 minutes' },
{ value: '15', label: '15 minutes' },
{ value: '30', label: '30 minutes' },
{ value: '60', label: '1 hour' },
{ value: 'custom', label: 'Custom' },
];
const showCustomInput = ref(false);
const customMinutes = ref(props.minutes);
const selectedInterval = ref('');
// Compute the current interval value based on props
const currentInterval = computed(() => {
const predefined = predefinedIntervals.find(interval =>
interval.value !== 'custom' && parseInt(interval.value) === props.minutes
);
return predefined ? predefined.value : 'custom';
});
// Initialize selectedInterval
const initializeSelectedInterval = () => {
selectedInterval.value = currentInterval.value;
showCustomInput.value = selectedInterval.value === 'custom';
if (showCustomInput.value) {
customMinutes.value = props.minutes;
}
};
function handleIntervalChange(value: string) {
selectedInterval.value = value;
if (value === 'custom') {
showCustomInput.value = true;
// Update minutes to current custom value to ensure "custom" shows as selected
updateMinutes(customMinutes.value);
} else {
showCustomInput.value = false;
const minutes = parseInt(value);
updateMinutes(minutes);
}
}
function handleCustomMinutesChange(value: string | number) {
const numValue = typeof value === 'string' ? parseInt(value) : value;
if (!isNaN(numValue) && numValue > 0) {
customMinutes.value = numValue;
updateMinutes(numValue);
}
}
// Watch for changes in props.minutes
watch(() => props.minutes, (newMinutes) => {
customMinutes.value = newMinutes;
initializeSelectedInterval();
}, { immediate: true });
watch(currentInterval, () => {
initializeSelectedInterval();
});
// Active styling similar to ReportingFilterBadge
const activeClass = computed(() => {
if (props.enabled) {
return 'border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';
}
return '';
});
const iconClass = computed(() => {
return twMerge(
'w-4 h-4',
props.enabled ? 'dark:text-accent-300/80 text-accent-400/80' : 'text-muted-foreground opacity-50'
);
});
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button
variant="outline"
size="sm"
:class="twMerge(activeClass)">
<ArrowsUpDownIcon :class="iconClass" />
Rounding {{ enabled ? 'on' : 'off' }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-72 p-4">
<div class="space-y-4">
<div>
<div class="flex items-center justify-between">
<InputLabel for="enable-rounding" value="Enable Rounding" />
<Switch
id="enable-rounding"
:model-value="enabled"
class="data-[state=checked]:bg-accent-500"
@update:model-value="updateEnabled" />
</div>
<div class="mb-3 pb-2 pt-1 text-xs text-muted-foreground border-b border-border-secondary text-text-tertiary">
Rounding is applied to each individual time entry, not to the accumulated total.
</div>
</div>
<div>
<InputLabel for="rounding-type" value="Rounding Type" class="mb-2" />
<Select
:model-value="type"
:disabled="!enabled"
@update:model-value="(value) => updateType(value as TimeEntryRoundingType)">
<SelectTrigger id="rounding-type" size="small" class="w-full" :disabled="!enabled">
<SelectValue placeholder="Select rounding type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="up">Round Up</SelectItem>
<SelectItem value="down">Round Down</SelectItem>
<SelectItem value="nearest">Round Nearest</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<InputLabel for="minutes-interval" value="Minutes Interval" class="mb-2" />
<Select
:model-value="selectedInterval"
:disabled="!enabled"
@update:model-value="(value) => handleIntervalChange(value as string)">
<SelectTrigger id="minutes-interval" size="small" class="w-full" :disabled="!enabled">
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="interval in predefinedIntervals"
:key="interval.value"
:value="interval.value">
{{ interval.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="showCustomInput" class="mt-2">
<NumberField
id="custom-minutes"
:model-value="customMinutes"
size="small"
:min="1"
:max="1440"
:disabled="!enabled"
class="text-sm"
@update:model-value="handleCustomMinutesChange">
<NumberFieldContent>
<NumberFieldDecrement :disabled="!enabled" />
<NumberFieldInput placeholder="Enter custom minutes" :disabled="!enabled" />
<NumberFieldIncrement :disabled="!enabled" />
</NumberFieldContent>
</NumberField>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -43,7 +43,7 @@ const isRunningInDifferentOrganization = computed(() => {
</div> </div>
</div> </div>
<div> <div>
<div class="text-text-secondary font-medium text-xs"> <div class="text-text-secondary font-extrabold text-xs">
Current Timer Current Timer
</div> </div>
<div class="text-text-primary font-medium text-lg"> <div class="text-text-primary font-medium text-lg">

View File

@@ -49,7 +49,7 @@ const option = computed(() => ({
fontSize: 16, fontSize: 16,
fontWeight: 600, fontWeight: 600,
margin: 24, margin: 24,
fontFamily: 'Inter, sans-serif', fontFamily: 'Outfit, sans-serif',
}, },
axisTick: { axisTick: {
lineStyle: { lineStyle: {

View File

@@ -18,9 +18,9 @@ defineProps<{
<template> <template>
<div <div
class="px-3.5 py-2 flex justify-between @container border-b border-b-background-separator"> class="px-3.5 py-2 flex justify-between @container border-b border-card-background-separator">
<div class="flex items-center min-w-[70px]"> <div class="flex items-center min-w-[70px]">
<p class="font-medium text-sm text-text-primary"> <p class="font-semibold text-sm text-text-primary">
{{ formatHumanReadableDate(date) }} {{ formatHumanReadableDate(date) }}
</p> </p>
</div> </div>
@@ -28,7 +28,7 @@ defineProps<{
<DayOverviewCardChart :history="history"></DayOverviewCardChart> <DayOverviewCardChart :history="history"></DayOverviewCardChart>
</div> </div>
<div <div
class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-medium"> class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-semibold">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(
duration, duration,

View File

@@ -47,7 +47,7 @@ async function startTaskTimer() {
<template> <template>
<div <div
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-background-separator"> class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
<div class="col-span-4"> <div class="col-span-4">
<p class="font-medium text-text-primary text-sm pb-1 truncate"> <p class="font-medium text-text-primary text-sm pb-1 truncate">
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span> <span v-if="timeEntry.description"> {{ timeEntry.description }}</span>

View File

@@ -7,7 +7,7 @@ defineProps<{
</script> </script>
<template> <template>
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator"> <div class="px-4 py-2 2xl:py-3 border-b border-card-background-separator">
<div class="col-span-2"> <div class="col-span-2">
<div class="flex justify-between"> <div class="flex justify-between">
<p class="font-semibold text-sm text-text-primary"> <p class="font-semibold text-sm text-text-primary">

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