mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
24 Commits
feature/po
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aedb9a016d | ||
|
|
43073b5be2 | ||
|
|
9589c9106d | ||
|
|
8a0d2235a8 | ||
|
|
38f38790d5 | ||
|
|
e3cfc155b8 | ||
|
|
4b726635b2 | ||
|
|
e1185af281 | ||
|
|
f9c0d64f82 | ||
|
|
3d58f570bd | ||
|
|
400bc434b9 | ||
|
|
2ab28001be | ||
|
|
62d2f4bf4e | ||
|
|
3d4b20f7c8 | ||
|
|
155ed62fcc | ||
|
|
5daa6f2a25 | ||
|
|
47aa65d959 | ||
|
|
b0e638c28b | ||
|
|
24b62d4643 | ||
|
|
dd928508fd | ||
|
|
ead9cf2185 | ||
|
|
7578beb271 | ||
|
|
dc21ac8352 | ||
|
|
4de7868851 |
5
.github/workflows/phpunit.yml
vendored
5
.github/workflows/phpunit.yml
vendored
@@ -6,10 +6,13 @@ jobs:
|
||||
phpunit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
postgres_version: [ 15, 16, 17 ]
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
image: postgres:15
|
||||
image: postgres:${{ matrix.postgres_version }}
|
||||
env:
|
||||
PGPASSWORD: 'root'
|
||||
POSTGRES_DB: 'laravel'
|
||||
|
||||
@@ -26,7 +26,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
/**
|
||||
* Create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
* @param array<string, mixed> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -59,8 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
$user->updateProfilePhoto($input['photo']);
|
||||
}
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
if ($input['email'] !== $user->email) {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
|
||||
@@ -57,7 +57,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return array_filter([
|
||||
return [
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
@@ -75,7 +75,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Auth;
|
||||
|
||||
use App\Mail\AuthApiTokenExpirationReminderMail;
|
||||
use App\Mail\AuthApiTokenExpiredMail;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class AuthSendReminderForExpiringApiTokensCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'auth:send-mails-expiring-api-tokens '.
|
||||
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if ($dryRun) {
|
||||
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
|
||||
}
|
||||
|
||||
$this->comment('Sending reminder emails about expiring API tokens...');
|
||||
$sentMails = 0;
|
||||
Token::query()
|
||||
->where('expires_at', '<=', Carbon::now()->addDays(7))
|
||||
->whereNull('reminder_sent_at')
|
||||
->with([
|
||||
'client',
|
||||
'user',
|
||||
])
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->isApiToken(true)
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, Token> $tokens */
|
||||
foreach ($tokens as $token) {
|
||||
$user = $token->user;
|
||||
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey());
|
||||
$sentMails++;
|
||||
if (! $dryRun) {
|
||||
Mail::to($user->email)
|
||||
->queue(new AuthApiTokenExpirationReminderMail($token, $user));
|
||||
$token->reminder_sent_at = Carbon::now();
|
||||
$token->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->comment('Finished sending '.$sentMails.' expiring API token emails...');
|
||||
|
||||
$this->comment('Sent emails about expired API tokens');
|
||||
$sentMails = 0;
|
||||
Token::query()
|
||||
->where('expires_at', '<=', Carbon::now())
|
||||
->whereNull('expired_info_sent_at')
|
||||
->with([
|
||||
'client',
|
||||
'user',
|
||||
])
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->isApiToken(true)
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, Token> $tokens */
|
||||
foreach ($tokens as $token) {
|
||||
$user = $token->user;
|
||||
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey());
|
||||
$sentMails++;
|
||||
if (! $dryRun) {
|
||||
Mail::to($user->email)
|
||||
->queue(new AuthApiTokenExpiredMail($token, $user));
|
||||
$token->expired_info_sent_at = Carbon::now();
|
||||
$token->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->comment('Finished sending '.$sentMails.' expired API token emails...');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ class Kernel extends ConsoleKernel
|
||||
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
||||
->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')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||
->twiceDaily();
|
||||
@@ -28,7 +32,7 @@ class Kernel extends ConsoleKernel
|
||||
|
||||
$schedule->command('self-host:database-consistency')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||
->twiceDaily();
|
||||
->everySixHours();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
16
app/Enums/TimeEntryRoundingType.php
Normal file
16
app/Enums/TimeEntryRoundingType.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum TimeEntryRoundingType: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Up = 'up';
|
||||
case Down = 'down';
|
||||
case Nearest = 'nearest';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'invitation_for_the_email_already_exists';
|
||||
}
|
||||
@@ -41,9 +41,7 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! ($collectingType = $this->openApiTransformer->transform($collectingClassType))) {
|
||||
return null;
|
||||
}
|
||||
$collectingType = $this->openApiTransformer->transform($collectingClassType);
|
||||
|
||||
$newType = new OpenApiObjectType;
|
||||
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
|
||||
|
||||
@@ -15,6 +15,7 @@ use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\BulkAction;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\DeleteBulkAction;
|
||||
use Filament\Tables\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
@@ -75,7 +76,8 @@ class FailedJobResource extends Resource
|
||||
->filters([])
|
||||
->bulkActions([
|
||||
BulkAction::make('retry')
|
||||
->label('Retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->label('Retry selected')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records): void {
|
||||
/** @var FailedJob $record */
|
||||
@@ -87,11 +89,13 @@ class FailedJobResource extends Resource
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
DeleteBulkAction::make(),
|
||||
])
|
||||
->actions([
|
||||
DeleteAction::make('Delete'),
|
||||
ViewAction::make('View'),
|
||||
DeleteAction::make(),
|
||||
ViewAction::make(),
|
||||
Action::make('retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->action(function (FailedJob $record): void {
|
||||
@@ -109,7 +113,6 @@ class FailedJobResource extends Resource
|
||||
return [
|
||||
'index' => ListFailedJobs::route('/'),
|
||||
'view' => ViewFailedJobs::route('/{record}'),
|
||||
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace App\Filament\Resources\FailedJobResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource;
|
||||
use App\Models\FailedJob;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
@@ -19,7 +19,8 @@ class ListFailedJobs extends ListRecords
|
||||
{
|
||||
return [
|
||||
Action::make('retry_all')
|
||||
->label('Retry all failed Jobs')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->label('Retry all')
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
Artisan::call('queue:retry all');
|
||||
@@ -30,7 +31,8 @@ class ListFailedJobs extends ListRecords
|
||||
}),
|
||||
|
||||
Action::make('delete_all')
|
||||
->label('Delete all failed Jobs')
|
||||
->icon('heroicon-o-trash')
|
||||
->label('Delete all')
|
||||
->requiresConfirmation()
|
||||
->color('danger')
|
||||
->action(function (): void {
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TokenResource\Pages;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
@@ -40,7 +39,7 @@ class TokenResource extends Resource
|
||||
->label('Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Select::make('user_id')
|
||||
Forms\Components\Select::make('owner_id')
|
||||
->label('User')
|
||||
->relationship(name: 'user', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
@@ -79,10 +78,12 @@ class TokenResource extends Resource
|
||||
Tables\Columns\TextColumn::make('client.name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('client.personal_access_client')
|
||||
Tables\Columns\IconColumn::make('personal_access_client')
|
||||
->state(function (Token $token): bool {
|
||||
return in_array('personal_access', $token->client->grant_types ?? [], true);
|
||||
})
|
||||
->boolean()
|
||||
->label('API token?')
|
||||
->sortable(),
|
||||
->label('API token?'),
|
||||
Tables\Columns\IconColumn::make('revoked')
|
||||
->boolean()
|
||||
->label('Revoked?')
|
||||
@@ -104,17 +105,11 @@ class TokenResource extends Resource
|
||||
->queries(
|
||||
true: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->where('personal_access_client', true);
|
||||
});
|
||||
return $query->isApiToken();
|
||||
},
|
||||
false: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->where('personal_access_client', false);
|
||||
});
|
||||
return $query->isApiToken(false);
|
||||
},
|
||||
blank: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
|
||||
@@ -8,9 +8,12 @@ use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
|
||||
use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
|
||||
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ApiTokenController extends Controller
|
||||
{
|
||||
@@ -28,7 +31,10 @@ class ApiTokenController extends Controller
|
||||
$user = $this->user();
|
||||
|
||||
$tokens = $user->tokens()
|
||||
->where('client_id', '=', config('passport.personal_access_client.id'))
|
||||
->whereHas('client', function (Builder $query): void {
|
||||
/** @var Builder<Client> $query */
|
||||
$query->whereJsonContains('grant_types', 'personal_access');
|
||||
})
|
||||
->get();
|
||||
|
||||
return new ApiTokenCollection($tokens);
|
||||
@@ -48,15 +54,21 @@ class ApiTokenController extends Controller
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||
throw new PersonalAccessClientIsNotConfiguredException;
|
||||
try {
|
||||
$token = $user->createToken($request->getName(), ['*']);
|
||||
|
||||
/** @var Token $tokenModel */
|
||||
$tokenModel = $token->getToken();
|
||||
|
||||
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
if (Str::contains($exception->getMessage(), ['Personal access client not found'])) {
|
||||
throw new PersonalAccessClientIsNotConfiguredException;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$token = $user->createToken($request->getName(), ['*']);
|
||||
/** @var Token $tokenModel */
|
||||
$tokenModel = $token->token;
|
||||
|
||||
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,13 +83,10 @@ class ApiTokenController extends Controller
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||
throw new PersonalAccessClientIsNotConfiguredException;
|
||||
}
|
||||
if ($apiToken->user_id !== $user->getKey()) {
|
||||
throw new AuthorizationException('API token does not belong to user');
|
||||
}
|
||||
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
|
||||
if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {
|
||||
throw new AuthorizationException('API token is not a personal access token');
|
||||
}
|
||||
|
||||
@@ -97,13 +106,10 @@ class ApiTokenController extends Controller
|
||||
{
|
||||
$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()) {
|
||||
throw new AuthorizationException('API token does not belong to user');
|
||||
}
|
||||
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
|
||||
if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {
|
||||
throw new AuthorizationException('API token is not a personal access token');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
|
||||
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
|
||||
@@ -50,6 +51,7 @@ class InvitationController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
* @throws InvitationForTheEmailAlreadyExistsApiException
|
||||
*
|
||||
* @operationId invite
|
||||
*/
|
||||
|
||||
@@ -10,12 +10,14 @@ use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
use App\Http\Requests\V1\Member\MemberDestroyRequest;
|
||||
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
||||
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
|
||||
use App\Http\Requests\V1\Member\MemberUpdateRequest;
|
||||
@@ -100,11 +102,13 @@ class MemberController extends Controller
|
||||
*
|
||||
* @operationId removeMember
|
||||
*/
|
||||
public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||
public function destroy(MemberDestroyRequest $request, Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'members:delete', $member);
|
||||
|
||||
$memberService->removeMember($member, $organization);
|
||||
$deleteRelated = $request->getDeleteRelated();
|
||||
|
||||
$memberService->removeMember($member, $organization, $deleteRelated);
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
@@ -170,6 +174,7 @@ class MemberController extends Controller
|
||||
* @throws UserNotPlaceholderApiException
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
|
||||
* @throws InvitationForTheEmailAlreadyExistsApiException
|
||||
*
|
||||
* @operationId invitePlaceholder
|
||||
*/
|
||||
|
||||
@@ -73,7 +73,9 @@ class ReportController extends Controller
|
||||
false,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
true
|
||||
true,
|
||||
$report->properties->roundingType,
|
||||
$report->properties->roundingMinutes,
|
||||
);
|
||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesQuery->clone(),
|
||||
@@ -84,7 +86,9 @@ class ReportController extends Controller
|
||||
true,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
true
|
||||
true,
|
||||
$report->properties->roundingType,
|
||||
$report->properties->roundingMinutes,
|
||||
);
|
||||
|
||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||
|
||||
@@ -107,6 +107,8 @@ class ReportController extends Controller
|
||||
}
|
||||
}
|
||||
$properties->timezone = $timezone;
|
||||
$properties->roundingType = $request->getPropertyRoundingType();
|
||||
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
|
||||
$report->properties = $properties;
|
||||
if ($isPublic) {
|
||||
$report->share_secret = $reportService->generateSecret();
|
||||
|
||||
@@ -33,6 +33,7 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
|
||||
use App\Service\ReportExport\TimeEntriesReportExport;
|
||||
use App\Service\TimeEntryAggregationService;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use App\Service\TimeEntryService;
|
||||
use App\Service\TimezoneService;
|
||||
use Gotenberg\Exceptions\GotenbergApiErrored;
|
||||
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
||||
@@ -47,6 +48,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
@@ -140,8 +142,15 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
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()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->select($select)
|
||||
->orderBy('start', 'desc');
|
||||
|
||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||
@@ -183,6 +192,8 @@ class TimeEntryController extends Controller
|
||||
$user = $this->user();
|
||||
$timezone = $user->timezone;
|
||||
$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->with([
|
||||
@@ -207,8 +218,9 @@ class TimeEntryController extends Controller
|
||||
if ($viewFile === false) {
|
||||
throw new \LogicException('View file not found');
|
||||
}
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesQuery->clone()->reorder()->withOnly([]),
|
||||
$timeEntriesAggregateQuery,
|
||||
null,
|
||||
null,
|
||||
$user->timezone,
|
||||
@@ -216,7 +228,9 @@ class TimeEntryController extends Controller
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes,
|
||||
);
|
||||
$html = Blade::render($viewFile, [
|
||||
'timeEntries' => $timeEntriesQuery->get(),
|
||||
@@ -324,6 +338,8 @@ class TimeEntryController extends Controller
|
||||
$group1Type = $request->getGroup();
|
||||
$group2Type = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$roundingType = $request->getRoundingType();
|
||||
$roundingMinutes = $request->getRoundingMinutes();
|
||||
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery,
|
||||
@@ -334,7 +350,9 @@ class TimeEntryController extends Controller
|
||||
$request->getFillGapsInTimeGroups(),
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -373,6 +391,8 @@ class TimeEntryController extends Controller
|
||||
$group = $request->getGroup();
|
||||
$subGroup = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$roundingType = $request->getRoundingType();
|
||||
$roundingMinutes = $request->getRoundingMinutes();
|
||||
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
@@ -383,7 +403,9 @@ class TimeEntryController extends Controller
|
||||
false,
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
@@ -394,7 +416,9 @@ class TimeEntryController extends Controller
|
||||
true,
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
$currency = $organization->currency;
|
||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||
@@ -477,7 +501,7 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* @return Builder<TimeEntry>
|
||||
*/
|
||||
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
|
||||
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
|
||||
{
|
||||
$timeEntriesQuery = TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization');
|
||||
|
||||
@@ -43,7 +43,10 @@ class Controller extends BaseController
|
||||
/** @var Member|null $member */
|
||||
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
|
||||
if ($member === null) {
|
||||
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
|
||||
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization', [
|
||||
'user' => $user->getKey(),
|
||||
'organization' => $organization->getKey(),
|
||||
]);
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
@@ -20,8 +19,7 @@ class EnsureEmailIsVerified
|
||||
{
|
||||
if (! app()->isLocal()) {
|
||||
if ($request->user() === null ||
|
||||
($request->user() instanceof MustVerifyEmail &&
|
||||
! $request->user()->hasVerifiedEmail())) {
|
||||
(! $request->user()->hasVerifiedEmail())) {
|
||||
return $request->expectsJson()
|
||||
? abort(403, 'Your email address is not verified.')
|
||||
: Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));
|
||||
|
||||
@@ -50,7 +50,7 @@ class HandleInertiaRequests extends Middleware
|
||||
return array_merge(parent::share($request), [
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'has_invoicing_extension' => $hasInvoicing,
|
||||
'billing' => $billing !== null && $currentOrganization !== null ? [
|
||||
'billing' => $currentOrganization !== null ? [
|
||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||
|
||||
@@ -26,7 +26,7 @@ class ShareInertiaData
|
||||
{
|
||||
/** @var PermissionStore $permissions */
|
||||
$permissions = app(PermissionStore::class);
|
||||
Inertia::share(array_filter([
|
||||
Inertia::share([
|
||||
'jetstream' => function () use ($request) {
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
@@ -101,7 +101,7 @@ class ShareInertiaData
|
||||
return [$key => $bag->messages()];
|
||||
})->all();
|
||||
},
|
||||
]));
|
||||
]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,8 @@ namespace App\Http\Requests\V1\Invitation;
|
||||
use App\Enums\Role;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
@@ -29,10 +26,6 @@ class InvitationStoreRequest extends BaseFormRequest
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
|
||||
/** @var Builder<OrganizationInvitation> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->withCustomTranslation('validation.invitation_already_exists'),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
|
||||
35
app/Http/Requests/V1/Member/MemberDestroyRequest.php
Normal file
35
app/Http/Requests/V1/Member/MemberDestroyRequest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Member;
|
||||
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
*/
|
||||
class MemberDestroyRequest extends BaseFormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'delete_related' => [
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getDeleteRelated(): bool
|
||||
{
|
||||
return $this->input('delete_related', 'false') === 'true';
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Report;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Organization;
|
||||
@@ -128,6 +129,18 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
'nullable',
|
||||
'timezone:all',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'properties.rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'properties.rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -205,4 +218,22 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
{
|
||||
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry;
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
@@ -164,6 +165,18 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -211,4 +224,22 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
{
|
||||
return ExportFormat::from($this->validated('format'));
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
@@ -146,6 +147,18 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -173,4 +186,22 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
{
|
||||
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
@@ -133,6 +134,18 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -170,4 +183,22 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
{
|
||||
return ExportFormat::from($this->validated('format'));
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
@@ -11,8 +12,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Contracts\Validation\Rule as RuleContract;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -23,7 +26,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
* @return array<string, array<string|ValidationRule|RuleContract>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -136,6 +139,18 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||
'rounding_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryRoundingType::class),
|
||||
],
|
||||
// Defines the length of the interval that the time entry rounding rounds to.
|
||||
'rounding_minutes' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'integer',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -153,4 +168,22 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
{
|
||||
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,11 @@ use App\Http\Resources\PaginatedResourceCollection;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
private bool $showBillableRates;
|
||||
|
||||
/**
|
||||
* @param LengthAwarePaginator<Project> $resource
|
||||
*/
|
||||
public function __construct($resource, bool $showBillableRates)
|
||||
{
|
||||
parent::__construct($resource);
|
||||
|
||||
@@ -58,6 +58,10 @@ class DetailedReportResource extends BaseResource
|
||||
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||
'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 */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
|
||||
44
app/Mail/AuthApiTokenExpirationReminderMail.php
Normal file
44
app/Mail/AuthApiTokenExpirationReminderMail.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AuthApiTokenExpirationReminderMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public Token $token;
|
||||
|
||||
public User $user;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Token $token, User $user)
|
||||
{
|
||||
$this->token = $token;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.auth-api-expiration-reminder', [
|
||||
'profileUrl' => URL::to('user/profile'),
|
||||
'tokenName' => $this->token->name,
|
||||
])
|
||||
->subject(__('Your API token will expire in 7 days!'));
|
||||
}
|
||||
}
|
||||
44
app/Mail/AuthApiTokenExpiredMail.php
Normal file
44
app/Mail/AuthApiTokenExpiredMail.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AuthApiTokenExpiredMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public Token $token;
|
||||
|
||||
public User $user;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Token $token, User $user)
|
||||
{
|
||||
$this->token = $token;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.auth-api-token-expired', [
|
||||
'profileUrl' => URL::to('user/profile'),
|
||||
'tokenName' => $this->token->name,
|
||||
])
|
||||
->subject(__('Your API token has expired!'));
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
||||
* @property string $event
|
||||
* @property string $auditable_type
|
||||
* @property string $auditable_id
|
||||
* @property array|null $old_values
|
||||
* @property array|null $new_values
|
||||
* @property array<string, mixed>|null $old_values
|
||||
* @property array<string, mixed>|null $new_values
|
||||
* @property string|null $url
|
||||
* @property string|null $ip_address
|
||||
* @property string|null $user_agent
|
||||
|
||||
@@ -47,7 +47,7 @@ class Client extends Model implements AuditableContract
|
||||
];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Client>
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
@@ -55,7 +55,7 @@ class Client extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Project>
|
||||
* @return HasMany<Project, $this>
|
||||
*/
|
||||
public function projects(): HasMany
|
||||
{
|
||||
|
||||
@@ -25,8 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Organization $organization
|
||||
* @property-read User $user
|
||||
* @property-read Collection<ProjectMember> $projectMembers
|
||||
* @property-read Collection<TimeEntry> $timeEntries
|
||||
* @property-read Collection<int, ProjectMember> $projectMembers
|
||||
* @property-read Collection<int, TimeEntry> $timeEntries
|
||||
*
|
||||
* @method static MemberFactory factory()
|
||||
*/
|
||||
@@ -47,7 +47,7 @@ class Member extends JetstreamMembership implements AuditableContract
|
||||
protected $table = 'members';
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, Member>
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
@@ -55,7 +55,7 @@ class Member extends JetstreamMembership implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Member>
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
@@ -63,7 +63,7 @@ class Member extends JetstreamMembership implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TimeEntry>
|
||||
* @return HasMany<TimeEntry, $this>
|
||||
*/
|
||||
public function timeEntries(): HasMany
|
||||
{
|
||||
@@ -71,7 +71,7 @@ class Member extends JetstreamMembership implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProjectMember>
|
||||
* @return HasMany<ProjectMember, $this>
|
||||
*/
|
||||
public function projectMembers(): HasMany
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Jetstream\Events\TeamCreated;
|
||||
@@ -47,7 +48,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property IntervalFormat $interval_format
|
||||
* @property TimeFormat $time_format
|
||||
*
|
||||
* @method HasMany<OrganizationInvitation> teamInvitations()
|
||||
* @method HasMany<OrganizationInvitation, $this> teamInvitations()
|
||||
* @method static OrganizationFactory factory()
|
||||
*/
|
||||
class Organization extends JetstreamTeam implements AuditableContract
|
||||
@@ -79,7 +80,7 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
@@ -125,7 +126,7 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
/**
|
||||
* Get all the users that belong to the team.
|
||||
*
|
||||
* @return BelongsToMany<User>
|
||||
* @return BelongsToMany<User, $this, Pivot, 'membership'>
|
||||
*/
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
@@ -142,7 +143,7 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
/**
|
||||
* Get the owner of the team.
|
||||
*
|
||||
* @return BelongsTo<User, Organization>
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
@@ -150,7 +151,7 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Member>
|
||||
* @return HasMany<Member, $this>
|
||||
*/
|
||||
public function members(): HasMany
|
||||
{
|
||||
@@ -158,7 +159,7 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<User>
|
||||
* @return BelongsToMany<User, $this, Pivot, 'membership'>
|
||||
*/
|
||||
public function realUsers(): BelongsToMany
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
||||
/**
|
||||
* Get the organization that the invitation belongs to.
|
||||
*
|
||||
* @return BelongsTo<Organization, OrganizationInvitation>
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
@@ -63,7 +63,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
||||
/**
|
||||
* Get the organization that the invitation belongs to.
|
||||
*
|
||||
* @return BelongsTo<Organization, OrganizationInvitation>
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -4,6 +4,26 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,22 +5,36 @@ declare(strict_types=1);
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Database\Factories\Passport\ClientFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Passport\Client as PassportClient;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string|null $user_id
|
||||
* @property string|null $owner_id
|
||||
* @property string|null $owner_type
|
||||
* @property string $name
|
||||
* @property string|null $secret
|
||||
* @property string|null $provider
|
||||
* @property string $redirect
|
||||
* @property bool $personal_access_client
|
||||
* @property bool $password_client
|
||||
* @property array<string> $grant_types
|
||||
* @property array<string> $redirect_uris
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property bool $revoked
|
||||
*/
|
||||
class Client extends PassportClient
|
||||
{
|
||||
/** @use HasFactory<ClientFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Create a new factory instance for the model.
|
||||
*
|
||||
* @return ClientFactory
|
||||
*/
|
||||
protected static function newFactory(): Factory
|
||||
{
|
||||
return ClientFactory::new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
|
||||
|
||||
class PersonalAccessClient extends PassportPersonalAccessClient {}
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Factories\Passport\TokenFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -17,9 +19,15 @@ use Laravel\Passport\Token as PassportToken;
|
||||
* @property null|string $name
|
||||
* @property array<string> $scopes
|
||||
* @property bool $revoked
|
||||
* @property Carbon|null $reminder_sent_at
|
||||
* @property Carbon|null $expired_info_sent_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_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
|
||||
{
|
||||
@@ -29,10 +37,60 @@ class Token extends PassportToken
|
||||
/**
|
||||
* Get the client that the token belongs to.
|
||||
*
|
||||
* @return BelongsTo<Client, Token>
|
||||
* @return BelongsTo<Client, $this>
|
||||
*/
|
||||
// @phpstan-ignore method.childReturnType
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ class Project extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Project>
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
@@ -145,7 +145,7 @@ class Project extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Client, Project>
|
||||
* @return BelongsTo<Client, $this>
|
||||
*/
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
@@ -153,7 +153,7 @@ class Project extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProjectMember>
|
||||
* @return HasMany<ProjectMember, $this>
|
||||
*/
|
||||
public function members(): HasMany
|
||||
{
|
||||
@@ -161,7 +161,7 @@ class Project extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Task>
|
||||
* @return HasMany<Task, $this>
|
||||
*/
|
||||
public function tasks(): HasMany
|
||||
{
|
||||
@@ -169,7 +169,7 @@ class Project extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TimeEntry>
|
||||
* @return HasMany<TimeEntry, $this>
|
||||
*/
|
||||
public function timeEntries(): HasMany
|
||||
{
|
||||
|
||||
@@ -48,7 +48,7 @@ class ProjectMember extends Model implements AuditableContract
|
||||
];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, ProjectMember>
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
@@ -58,7 +58,7 @@ class ProjectMember extends Model implements AuditableContract
|
||||
/**
|
||||
* @deprecated Use member relationship instead
|
||||
*
|
||||
* @return BelongsTo<User, ProjectMember>
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
@@ -66,7 +66,7 @@ class ProjectMember extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Member, ProjectMember>
|
||||
* @return BelongsTo<Member, $this>
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -55,7 +55,7 @@ class Report extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Report>
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Collection<TimeEntry> $timeEntries
|
||||
* @property-read Collection<int, TimeEntry> $timeEntries
|
||||
* @property-read Organization $organization
|
||||
*
|
||||
* @method static TagFactory factory()
|
||||
@@ -47,7 +47,7 @@ class Tag extends Model implements AuditableContract
|
||||
];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Tag>
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -120,7 +120,7 @@ class Task extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, Task>
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
@@ -128,7 +128,7 @@ class Task extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Task>
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
@@ -136,7 +136,7 @@ class Task extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TimeEntry>
|
||||
* @return HasMany<TimeEntry, $this>
|
||||
*/
|
||||
public function timeEntries(): HasMany
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
|
||||
* @property Carbon|null $end
|
||||
* @property int|null $billable_rate Billable rate per hour in cents
|
||||
* @property bool $billable
|
||||
* @property array $tags
|
||||
* @property array<string> $tags
|
||||
* @property string $user_id
|
||||
* @property string $member_id
|
||||
* @property bool $is_imported
|
||||
@@ -45,7 +45,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
|
||||
* @property-read Client|null $client
|
||||
* @property string|null $task_id
|
||||
* @property-read Task|null $task
|
||||
* @property-read Collection<Tag> $tagsRelation
|
||||
* @property-read Collection<int, Tag> $tagsRelation
|
||||
*
|
||||
* @method Builder<TimeEntry> hasTag(Tag $tag)
|
||||
* @method static TimeEntryFactory factory()
|
||||
@@ -77,6 +77,26 @@ class TimeEntry extends Model implements AuditableContract
|
||||
'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)
|
||||
* These attributes can be regenerated at any time.
|
||||
@@ -154,7 +174,7 @@ class TimeEntry extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, TimeEntry>
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
@@ -162,7 +182,7 @@ class TimeEntry extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Member, TimeEntry>
|
||||
* @return BelongsTo<Member, $this>
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
@@ -170,7 +190,7 @@ class TimeEntry extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, TimeEntry>
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
@@ -178,7 +198,7 @@ class TimeEntry extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, TimeEntry>
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
@@ -186,7 +206,7 @@ class TimeEntry extends Model implements AuditableContract
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Task, TimeEntry>
|
||||
* @return BelongsTo<Task, $this>
|
||||
*/
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
@@ -196,7 +216,7 @@ class TimeEntry extends Model implements AuditableContract
|
||||
/**
|
||||
* This relation can be reconstructed via the task relation. It is only here for performance reasons.
|
||||
*
|
||||
* @return BelongsTo<Client, TimeEntry>
|
||||
* @return BelongsTo<Client, $this>
|
||||
*/
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -27,6 +28,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Jetstream\HasTeams;
|
||||
use Laravel\Passport\AuthCode;
|
||||
use Laravel\Passport\Contracts\OAuthenticatable;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
@@ -52,13 +54,13 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property Collection<int, TimeEntry> $timeEntries
|
||||
* @property Member $membership
|
||||
*
|
||||
* @method HasMany<Organization> ownedTeams()
|
||||
* @method HasMany<Organization, $this> ownedTeams()
|
||||
* @method static UserFactory factory()
|
||||
* @method static Builder<User> query()
|
||||
* @method Builder<User> belongsToOrganization(Organization $organization)
|
||||
* @method Builder<User> active()
|
||||
*/
|
||||
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail
|
||||
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail, OAuthenticatable
|
||||
{
|
||||
use CustomAuditable;
|
||||
use HasApiTokens;
|
||||
@@ -75,7 +77,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
@@ -86,7 +88,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
@@ -143,7 +145,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Organization>
|
||||
* @return BelongsToMany<Organization, $this, Pivot, 'membership'>
|
||||
*/
|
||||
public function organizations(): BelongsToMany
|
||||
{
|
||||
@@ -158,7 +160,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TimeEntry>
|
||||
* @return HasMany<TimeEntry, $this>
|
||||
*/
|
||||
public function timeEntries(): HasMany
|
||||
{
|
||||
@@ -166,7 +168,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, User>
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function currentOrganization(): BelongsTo
|
||||
{
|
||||
@@ -174,7 +176,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProjectMember>
|
||||
* @return HasMany<ProjectMember, $this>
|
||||
*/
|
||||
public function projectMembers(): HasMany
|
||||
{
|
||||
@@ -182,7 +184,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Token>
|
||||
* @return HasMany<Token, $this>
|
||||
*/
|
||||
public function accessTokens(): HasMany
|
||||
{
|
||||
@@ -190,24 +192,13 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<AuthCode>
|
||||
* @return HasMany<AuthCode, $this>
|
||||
*/
|
||||
public function authCodes(): HasMany
|
||||
{
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Providers;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Passport\AuthCode;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\PersonalAccessClient;
|
||||
use App\Models\Passport\RefreshToken;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Policies\OrganizationPolicy;
|
||||
@@ -51,7 +50,8 @@ class AuthServiceProvider extends ServiceProvider
|
||||
Passport::useRefreshTokenModel(RefreshToken::class);
|
||||
Passport::useAuthCodeModel(AuthCode::class);
|
||||
Passport::useClientModel(Client::class);
|
||||
Passport::usePersonalAccessClientModel(PersonalAccessClient::class);
|
||||
|
||||
Passport::authorizationView('auth.oauth.authorize');
|
||||
|
||||
// Passport::tokensExpireIn(now()->addDays(15));
|
||||
// Passport::refreshTokensExpireIn(now()->addDays(30));
|
||||
|
||||
@@ -187,6 +187,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'members:invite-placeholder',
|
||||
'members:make-placeholder',
|
||||
'members:merge-into',
|
||||
'members:delete',
|
||||
'members:update',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Brick\Money\Money;
|
||||
|
||||
class CurrencyService
|
||||
@@ -374,4 +375,12 @@ class CurrencyService
|
||||
|
||||
return $currencyCode;
|
||||
}
|
||||
|
||||
public function getRandomCurrencyCode(): string
|
||||
{
|
||||
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
||||
$currencyCodes = array_keys($currencies);
|
||||
|
||||
return $currencyCodes[array_rand($currencyCodes)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Service\Dto;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
@@ -59,6 +60,10 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -115,13 +120,14 @@ class ReportPropertiesDto implements Castable
|
||||
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
||||
$dto->weekStart = Weekday::from($data->weekStart);
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ReportPropertiesDto $value
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): string
|
||||
{
|
||||
if (! ($value instanceof ReportPropertiesDto)) {
|
||||
@@ -143,6 +149,8 @@ class ReportPropertiesDto implements Castable
|
||||
'historyGroup' => $value->historyGroup->value,
|
||||
'weekStart' => $value->weekStart->value,
|
||||
'timezone' => $value->timezone,
|
||||
'roundingType' => $value->roundingType?->value,
|
||||
'roundingMinutes' => $value->roundingMinutes,
|
||||
];
|
||||
|
||||
$jsonString = json_encode($data);
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Member;
|
||||
@@ -16,7 +17,7 @@ use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
class InvitationService
|
||||
{
|
||||
/**
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
|
||||
*/
|
||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||
{
|
||||
@@ -28,6 +29,13 @@ class InvitationService
|
||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||
}
|
||||
|
||||
if (OrganizationInvitation::query()
|
||||
->where('email', $email)
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->exists()) {
|
||||
throw new InvitationForTheEmailAlreadyExistsApiException;
|
||||
}
|
||||
|
||||
InvitingTeamMember::dispatch($organization, $email, $role->value);
|
||||
|
||||
$invitation = new OrganizationInvitation;
|
||||
|
||||
@@ -45,6 +45,9 @@ class MemberService
|
||||
$member->organization()->associate($organization);
|
||||
$member->role = $role->value;
|
||||
$member->save();
|
||||
|
||||
$user->currentOrganization()->associate($organization);
|
||||
$user->save();
|
||||
});
|
||||
|
||||
if (! $asSuperAdmin) {
|
||||
@@ -58,19 +61,41 @@ class MemberService
|
||||
* @throws CanNotRemoveOwnerFromOrganization
|
||||
* @throws EntityStillInUseApiException
|
||||
*/
|
||||
public function removeMember(Member $member, Organization $organization): void
|
||||
public function removeMember(Member $member, Organization $organization, bool $withRelations = false): void
|
||||
{
|
||||
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
|
||||
throw new EntityStillInUseApiException('member', 'time_entry');
|
||||
}
|
||||
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
|
||||
throw new EntityStillInUseApiException('member', 'project_member');
|
||||
}
|
||||
if ($member->role === Role::Owner->value) {
|
||||
throw new CanNotRemoveOwnerFromOrganization;
|
||||
}
|
||||
|
||||
$user = $member->user;
|
||||
$isPlaceholder = $user->is_placeholder;
|
||||
|
||||
if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {
|
||||
$user->currentTeam()->disassociate();
|
||||
$user->save();
|
||||
}
|
||||
|
||||
if ($withRelations) {
|
||||
TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->delete();
|
||||
ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->delete();
|
||||
} else {
|
||||
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
|
||||
throw new EntityStillInUseApiException('member', 'time_entry');
|
||||
}
|
||||
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
|
||||
throw new EntityStillInUseApiException('member', 'project_member');
|
||||
}
|
||||
}
|
||||
|
||||
$member->delete();
|
||||
|
||||
if ($isPlaceholder) {
|
||||
$user->delete();
|
||||
} else {
|
||||
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||
}
|
||||
|
||||
MemberRemoved::dispatch($member, $organization);
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ class PermissionStore
|
||||
/** @var Role|null $roleObj */
|
||||
$roleObj = Jetstream::findRole($role);
|
||||
|
||||
return $roleObj?->permissions ?? [];
|
||||
return $roleObj->permissions ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Service;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Client;
|
||||
use App\Models\Project;
|
||||
@@ -41,7 +42,7 @@ class TimeEntryAggregationService
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||
{
|
||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||
$group1Select = null;
|
||||
@@ -56,15 +57,14 @@ class TimeEntryAggregationService
|
||||
}
|
||||
}
|
||||
|
||||
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
||||
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
|
||||
|
||||
$timeEntriesQuery->selectRaw(
|
||||
($group1Select !== null ? $group1Select.' as group_1,' : '').
|
||||
($group2Select !== null ? $group2Select.' as group_2,' : '').
|
||||
' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'.
|
||||
' round(
|
||||
sum(
|
||||
extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60)
|
||||
)
|
||||
) as cost'
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||
);
|
||||
if ($groupBy !== null) {
|
||||
$timeEntriesQuery->groupBy($groupBy);
|
||||
@@ -164,9 +164,9 @@ class TimeEntryAggregationService
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||
{
|
||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
|
||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);
|
||||
|
||||
$keysGroup1 = [];
|
||||
$keysGroup2 = [];
|
||||
|
||||
42
app/Service/TimeEntryService.php
Normal file
42
app/Service/TimeEntryService.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use Illuminate\Support\Carbon;
|
||||
use LogicException;
|
||||
|
||||
class TimeEntryService
|
||||
{
|
||||
public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
||||
{
|
||||
if ($roundingType === null || $roundingMinutes === null) {
|
||||
return 'start';
|
||||
}
|
||||
if ($roundingMinutes < 1) {
|
||||
throw new LogicException('Rounding minutes must be greater than 0');
|
||||
}
|
||||
|
||||
return 'date_bin(\'1 minutes\', start, TIMESTAMP \'1970-01-01\')';
|
||||
}
|
||||
|
||||
public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
||||
{
|
||||
if ($roundingType === null || $roundingMinutes === null) {
|
||||
return 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||
}
|
||||
if ($roundingMinutes < 1) {
|
||||
throw new LogicException('Rounding minutes must be greater than 0');
|
||||
}
|
||||
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||
if ($roundingType === TimeEntryRoundingType::Down) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,31 +11,31 @@
|
||||
"datomatic/laravel-enum-helper": "^2.0.0",
|
||||
"dedoc/scramble": "^0.12.2",
|
||||
"filament/filament": "^3.2",
|
||||
"flowframe/laravel-trend": "^0.3.0",
|
||||
"flowframe/laravel-trend": "^0.4.0",
|
||||
"gotenberg/gotenberg-php": "^2.8",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"inertiajs/inertia-laravel": "^1.0",
|
||||
"inertiajs/inertia-laravel": "^2.0.3",
|
||||
"korridor/laravel-computed-attributes": "^3.1",
|
||||
"korridor/laravel-has-many-sync": "^3.1",
|
||||
"korridor/laravel-model-validation-rules": "^3.0",
|
||||
"laravel/framework": "^11.16.0",
|
||||
"laravel/framework": "^12.19.3",
|
||||
"laravel/jetstream": "^5.0",
|
||||
"laravel/octane": "^2.3",
|
||||
"laravel/passport": "^12.0",
|
||||
"laravel/passport": "^13.0.5",
|
||||
"laravel/tinker": "^2.8",
|
||||
"league/csv": "^9.16.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"league/iso3166": "^4.3",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"novadaemon/filament-pretty-json": "^2.2",
|
||||
"nwidart/laravel-modules": "^11.0.11",
|
||||
"owen-it/laravel-auditing": "^13.6",
|
||||
"pxlrbt/filament-environment-indicator": "^2.0",
|
||||
"nwidart/laravel-modules": "^12.0.4",
|
||||
"owen-it/laravel-auditing": "^14.0.0",
|
||||
"pxlrbt/filament-environment-indicator": "^2.1.0",
|
||||
"spatie/temporary-directory": "^2.2",
|
||||
"staudenmeir/eloquent-json-relations": "^1.1",
|
||||
"stechstudio/filament-impersonate": "^3.8",
|
||||
"tightenco/ziggy": "^2.1.0",
|
||||
"tpetry/laravel-postgresql-enhanced": "^2.0.0",
|
||||
"tpetry/laravel-postgresql-enhanced": "^3.0.0",
|
||||
"wikimedia/composer-merge-plugin": "^2.1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -43,14 +43,13 @@
|
||||
"brianium/paratest": "^7.3",
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
"fumeapp/modeltyper": "^3.0",
|
||||
"phpstan/phpstan": "1.12.0",
|
||||
"larastan/larastan": "^2.0",
|
||||
"larastan/larastan": "^3.5.0",
|
||||
"laravel/pint": "^1.0",
|
||||
"laravel/sail": "^1.18",
|
||||
"laravel/telescope": "^5.0",
|
||||
"mockery/mockery": "^1.4.4",
|
||||
"nunomaduro/collision": "^8.1",
|
||||
"phpunit/phpunit": "^11",
|
||||
"phpunit/phpunit": "^12",
|
||||
"spatie/laravel-ignition": "^2.0",
|
||||
"timacdonald/log-fake": "^2.1"
|
||||
},
|
||||
@@ -119,7 +118,8 @@
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": [
|
||||
"laravel/telescope"
|
||||
"laravel/telescope",
|
||||
"nwidart/laravel-modules"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
2458
composer.lock
generated
2458
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Nwidart\Modules\LaravelModulesServiceProvider;
|
||||
|
||||
return [
|
||||
|
||||
@@ -197,6 +198,7 @@ return [
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\JetstreamServiceProvider::class,
|
||||
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
||||
LaravelModulesServiceProvider::class,
|
||||
])->toArray(),
|
||||
|
||||
/*
|
||||
|
||||
@@ -34,31 +34,15 @@ return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Client UUIDs
|
||||
| Passport Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Passport uses auto-incrementing primary keys when assigning
|
||||
| IDs to clients. However, if Passport is installed using the provided
|
||||
| --uuids switch, this will be set to "true" and UUIDs will be used.
|
||||
| By default, Passport's models will utilize your application's default
|
||||
| database connection. If you wish to use a different connection you
|
||||
| may specify the configured name of the database connection here.
|
||||
|
|
||||
*/
|
||||
|
||||
'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'),
|
||||
],
|
||||
'connection' => env('PASSPORT_CONNECTION'),
|
||||
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ return [
|
||||
|
||||
'tasks' => [
|
||||
'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_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
|
||||
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\CurrencyService;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,7 @@ class OrganizationFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->unique()->company(),
|
||||
'currency' => $this->faker->currencyCode(),
|
||||
'currency' => app(CurrencyService::class)->getRandomCurrencyCode(),
|
||||
'billable_rate' => null,
|
||||
'user_id' => User::factory(),
|
||||
'personal_team' => true,
|
||||
|
||||
@@ -7,11 +7,12 @@ namespace Database\Factories\Passport;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Laravel\Passport\Database\Factories\ClientFactory as BaseClientFactory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Client>
|
||||
*/
|
||||
class ClientFactory extends Factory
|
||||
class ClientFactory extends BaseClientFactory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
@@ -22,24 +23,40 @@ class ClientFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'id' => $this->faker->uuid,
|
||||
'user_id' => null,
|
||||
'owner_id' => null,
|
||||
'owner_type' => null,
|
||||
'name' => $this->faker->company(),
|
||||
'secret' => $this->faker->regexify('[A-Za-z]{40}'),
|
||||
'provider' => 'users',
|
||||
'redirect' => $this->faker->url(),
|
||||
'personal_access_client' => false,
|
||||
'password_client' => false,
|
||||
'redirect_uris' => [$this->faker->url()],
|
||||
'grant_types' => [],
|
||||
'revoked' => false,
|
||||
'created_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
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
return [
|
||||
'personal_access_client' => true,
|
||||
'grant_types' => ['personal_access'],
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -48,7 +65,8 @@ class ClientFactory extends Factory
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($user): array {
|
||||
return [
|
||||
'user_id' => $user->getKey(),
|
||||
'owner_id' => $user->getKey(),
|
||||
'owner_type' => (new User)->getMorphClass(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ class TokenFactory extends Factory
|
||||
'created_at' => $this->faker->dateTime,
|
||||
'updated_at' => $this->faker->dateTime,
|
||||
'expires_at' => $this->faker->dateTime,
|
||||
'reminder_sent_at' => null,
|
||||
'expired_info_sent_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,16 @@ class TimeEntryFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
public function endWithDuration(Carbon $end, int $durationInSeconds): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($end, $durationInSeconds): array {
|
||||
return [
|
||||
'start' => $end->copy()->utc()->subSeconds($durationInSeconds),
|
||||
'end' => $end->copy()->utc(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function start(Carbon $start): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($start): array {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_device_codes', function (Blueprint $table): void {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->foreignUuid('client_id')->index();
|
||||
$table->char('user_code', 8)->unique();
|
||||
$table->text('scopes');
|
||||
$table->boolean('revoked');
|
||||
$table->dateTime('user_approved_at')->nullable();
|
||||
$table->dateTime('last_polled_at')->nullable();
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_device_codes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::drop('oauth_personal_access_clients');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::create('oauth_personal_access_clients', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->uuid('client_id');
|
||||
$table->foreign('client_id')
|
||||
->references('id')
|
||||
->on('oauth_clients')
|
||||
->onDelete('restrict')
|
||||
->onUpdate('cascade');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('oauth_clients')->update(['provider' => 'users']); // Change default provider if necessary
|
||||
|
||||
Schema::table('oauth_clients', function (Blueprint $table): void {
|
||||
$table->text('grant_types')->default('[]')->after('provider');
|
||||
$table->text('redirect_uris')->default('[]');
|
||||
$table->renameColumn('user_id', 'owner_id');
|
||||
$table->string('owner_type')->after('owner_id')->nullable();
|
||||
});
|
||||
|
||||
DB::table('oauth_clients')
|
||||
->where('redirect', '=', 'http://localhost')
|
||||
->where('personal_access_client', '=', true)
|
||||
->update(['redirect' => '']);
|
||||
|
||||
DB::table('oauth_clients')
|
||||
->whereNotNull('owner_id')
|
||||
->update(['owner_type' => 'user']); // Value might be class name of the owner model, depends on if you use "enforceMorphMap"
|
||||
|
||||
DB::table('oauth_clients')->eachById(function ($client): void {
|
||||
$grantTypes = ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'];
|
||||
$confidential = ! empty($client->secret);
|
||||
$noRedirect = empty($client->redirect);
|
||||
$redirectUris = $noRedirect ? [] : [$client->redirect];
|
||||
$firstParty = empty($client->owner_id);
|
||||
|
||||
if (! $noRedirect) {
|
||||
$grantTypes[] = 'authorization_code';
|
||||
$grantTypes[] = 'implicit';
|
||||
}
|
||||
|
||||
if ($confidential && $firstParty) {
|
||||
$grantTypes[] = 'client_credentials';
|
||||
}
|
||||
|
||||
if ($client->personal_access_client && $confidential) {
|
||||
$grantTypes[] = 'personal_access';
|
||||
}
|
||||
|
||||
if ($client->password_client) {
|
||||
$grantTypes[] = 'password';
|
||||
}
|
||||
|
||||
DB::table('oauth_clients')
|
||||
->where('id', $client->id)
|
||||
->update([
|
||||
'redirect_uris' => $redirectUris,
|
||||
'grant_types' => $grantTypes,
|
||||
]);
|
||||
});
|
||||
|
||||
Schema::table('oauth_clients', function (Blueprint $table): void {
|
||||
$table->dropForeign(['user_id']);
|
||||
$table->index(['owner_id', 'owner_type']);
|
||||
$table->dropColumn('redirect');
|
||||
$table->dropColumn('personal_access_client');
|
||||
$table->dropColumn('password_client');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('oauth_clients', function (Blueprint $table): void {
|
||||
$table->dropIndex(['owner_id', 'owner_type']);
|
||||
$table->renameColumn('owner_id', 'user_id');
|
||||
$table->foreign('user_id')
|
||||
->on('users')
|
||||
->references('id')
|
||||
->onDelete('cascade')
|
||||
->onUpdate('cascade');
|
||||
$table->string('redirect')->nullable();
|
||||
$table->boolean('personal_access_client')->default(false);
|
||||
$table->boolean('password_client')->default(false);
|
||||
});
|
||||
|
||||
DB::table('oauth_clients')->eachById(function ($client): void {
|
||||
$redirectUris = json_decode($client->redirect_uris);
|
||||
$grantTypes = json_decode($client->grant_types);
|
||||
|
||||
DB::table('oauth_clients')
|
||||
->where('id', $client->id)
|
||||
->update([
|
||||
'redirect' => $redirectUris[0] ?? '', // redirect not nullable
|
||||
'password_client' => in_array('password', $grantTypes, true)
|
||||
&& in_array('refresh_token', $grantTypes, true),
|
||||
'personal_access_client' => in_array('personal_access', $grantTypes, true),
|
||||
]);
|
||||
});
|
||||
|
||||
Schema::table('oauth_clients', function (Blueprint $table): void {
|
||||
$table->dropColumn(['grant_types', 'redirect_uris', 'owner_type']);
|
||||
$table->string('redirect')->nullable(false)->change();
|
||||
$table->boolean('personal_access_client')->default(null)->change();
|
||||
$table->boolean('password_client')->default(null)->change();
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
35
database/migrations/2025_07_15_105949_hash_oauth_clients.php
Normal file
35
database/migrations/2025_07_15_105949_hash_oauth_clients.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// This could be optimized to run all the updates in the eachById
|
||||
DB::table('oauth_clients')->whereNotNull('secret')->eachById(function ($client): void {
|
||||
$secret = $client->secret;
|
||||
if (Hash::isHashed($secret) && ! Hash::needsRehash($secret)) {
|
||||
return; // Already hashed and not needing rehash
|
||||
}
|
||||
DB::table('oauth_clients')
|
||||
->where('id', $client->id)
|
||||
->update([
|
||||
'secret' => Hash::make($secret),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// This can not be reversed without a backup of the original secrets, for security reasons.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
|
||||
$table->dateTime('reminder_sent_at')->nullable();
|
||||
$table->dateTime('expired_info_sent_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
|
||||
$table->dropColumn('reminder_sent_at');
|
||||
$table->dropColumn('expired_info_sent_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -24,7 +24,6 @@ use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Passport\AuthCode;
|
||||
use Laravel\Passport\Client as PassportClient;
|
||||
use Laravel\Passport\ClientRepository;
|
||||
use Laravel\Passport\PersonalAccessClient;
|
||||
use Laravel\Passport\RefreshToken;
|
||||
use Laravel\Passport\Token;
|
||||
|
||||
@@ -37,6 +36,18 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
$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(
|
||||
null,
|
||||
'desktop',
|
||||
@@ -46,17 +57,16 @@ class DatabaseSeeder extends Seeder
|
||||
false,
|
||||
false
|
||||
);
|
||||
*/
|
||||
|
||||
$personalAccessClient = new PassportClient;
|
||||
$personalAccessClient->id = config('passport.personal_access_client.id');
|
||||
$personalAccessClient->secret = config('passport.personal_access_client.secret');
|
||||
$personalAccessClient->name = 'API';
|
||||
$personalAccessClient->redirect = 'http://localhost';
|
||||
$personalAccessClient->user_id = null;
|
||||
$personalAccessClient->redirect_uris = ['http://localhost'];
|
||||
$personalAccessClient->revoked = false;
|
||||
$personalAccessClient->provider = null;
|
||||
$personalAccessClient->personal_access_client = true;
|
||||
$personalAccessClient->password_client = false;
|
||||
$personalAccessClient->provider = 'users';
|
||||
$personalAccessClient->grant_types = ['personal_access'];
|
||||
$personalAccessClient->save();
|
||||
|
||||
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
|
||||
@@ -197,7 +207,6 @@ class DatabaseSeeder extends Seeder
|
||||
DB::table((new RefreshToken)->getTable())->delete();
|
||||
DB::table((new Token)->getTable())->delete();
|
||||
DB::table((new AuthCode)->getTable())->delete();
|
||||
DB::table((new PersonalAccessClient)->getTable())->delete();
|
||||
DB::table((new PassportClient)->getTable())->delete();
|
||||
|
||||
// Internal tables
|
||||
|
||||
@@ -1,47 +1,30 @@
|
||||
# Accepted values: 8.3 - 8.2
|
||||
ARG PHP_VERSION=8.3
|
||||
|
||||
ARG FRANKENPHP_VERSION=latest
|
||||
|
||||
ARG COMPOSER_VERSION=latest
|
||||
|
||||
ARG FRANKENPHP_VERSION=1.8
|
||||
ARG COMPOSER_VERSION=2.8
|
||||
ARG BUN_VERSION="latest"
|
||||
ARG APP_ENV
|
||||
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 dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-builder-php${PHP_VERSION} AS upstream
|
||||
|
||||
ARG DOCKER_FILES_BASE_PATH
|
||||
ARG TARGETPLATFORM
|
||||
COPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy
|
||||
|
||||
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 org.opencontainers.image.title="solidtime"
|
||||
@@ -53,18 +36,22 @@ ARG WWWUSER=1000
|
||||
ARG WWWGROUP=1000
|
||||
ARG TZ=UTC
|
||||
ARG APP_DIR=/var/www/html
|
||||
ARG APP_ENV
|
||||
ARG APP_HOST
|
||||
ARG DOCKER_FILES_BASE_PATH
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
TERM=xterm-color \
|
||||
WITH_HORIZON=false \
|
||||
WITH_SCHEDULER=false \
|
||||
OCTANE_SERVER=frankenphp \
|
||||
TZ=${TZ} \
|
||||
USER=octane \
|
||||
ROOT=${APP_DIR} \
|
||||
APP_ENV=${APP_ENV} \
|
||||
COMPOSER_FUND=0 \
|
||||
COMPOSER_MAX_PARALLEL_HTTP=24 \
|
||||
XDG_CONFIG_HOME=${APP_DIR}/.config \
|
||||
XDG_DATA_HOME=${APP_DIR}/.data
|
||||
XDG_DATA_HOME=${APP_DIR}/.data \
|
||||
SERVER_NAME=${APP_HOST}
|
||||
|
||||
WORKDIR ${ROOT}
|
||||
|
||||
@@ -78,14 +65,16 @@ RUN apt-get update; \
|
||||
apt-get install -yqq --no-install-recommends --show-progress \
|
||||
apt-utils \
|
||||
curl \
|
||||
gcc \
|
||||
wget \
|
||||
nano \
|
||||
vim \
|
||||
git \
|
||||
ncdu \
|
||||
procps \
|
||||
unzip \
|
||||
ca-certificates \
|
||||
supervisor \
|
||||
libsodium-dev \
|
||||
libbrotli-dev \
|
||||
# Install PHP extensions (included with dunglas/frankenphp)
|
||||
&& install-php-extensions \
|
||||
bz2 \
|
||||
@@ -99,6 +88,8 @@ RUN apt-get update; \
|
||||
exif \
|
||||
pdo_mysql \
|
||||
zip \
|
||||
uv \
|
||||
vips \
|
||||
intl \
|
||||
gd \
|
||||
redis \
|
||||
@@ -128,27 +119,34 @@ RUN arch="$(uname -m)" \
|
||||
|
||||
RUN userdel --remove --force www-data \
|
||||
&& 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} \
|
||||
&& chmod -R a+rw ${ROOT} /var/{log,run}
|
||||
|
||||
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}
|
||||
|
||||
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} --from=vendor /usr/bin/composer /usr/bin/composer
|
||||
|
||||
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 \
|
||||
# --no-dev \
|
||||
@@ -158,22 +156,47 @@ COPY --chown=${USER}:${USER} --from=vendor /usr/bin/composer /usr/bin/composer
|
||||
# --no-scripts \
|
||||
# --audit
|
||||
|
||||
COPY --chown=${USER}:${USER} . .
|
||||
#COPY --chown=${USER}:${USER} --from=build ${ROOT}/public public
|
||||
###########################################
|
||||
# Build frontend assets with Bun
|
||||
###########################################
|
||||
|
||||
#FROM oven/bun:${BUN_VERSION} AS build
|
||||
#
|
||||
#ARG APP_ENV
|
||||
#
|
||||
#ENV ROOT=/var/www/html \
|
||||
# APP_ENV=${APP_ENV} \
|
||||
# NODE_ENV=${APP_ENV:-production}
|
||||
#
|
||||
#WORKDIR ${ROOT}
|
||||
#
|
||||
#COPY --link package.json bun.lock* ./
|
||||
#
|
||||
#RUN bun install --frozen-lockfile
|
||||
#
|
||||
#COPY --link . .
|
||||
#COPY --link --from=common ${ROOT}/vendor vendor
|
||||
#
|
||||
#RUN bun run build
|
||||
|
||||
###########################################
|
||||
|
||||
#FROM common AS runner
|
||||
|
||||
USER ${USER}
|
||||
|
||||
ENV WITH_HORIZON=false \
|
||||
WITH_SCHEDULER=false \
|
||||
WITH_REVERB=false
|
||||
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
||||
|
||||
RUN mkdir -p \
|
||||
storage/framework/{sessions,views,cache,testing} \
|
||||
storage/logs \
|
||||
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 \
|
||||
# --classmap-authoritative \
|
||||
# --no-interaction \
|
||||
@@ -183,12 +206,9 @@ COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/ph
|
||||
|
||||
RUN cat .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
|
||||
|
||||
ENTRYPOINT ["start-container"]
|
||||
|
||||
#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1
|
||||
|
||||
35
docker/prod/deployment/healthcheck
Normal file
35
docker/prod/deployment/healthcheck
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
set -e
|
||||
|
||||
container_mode=${CONTAINER_MODE:-"http"}
|
||||
|
||||
if [ "${container_mode}" = "http" ]; then
|
||||
php "${ROOT}/artisan" octane:status
|
||||
elif [ "${container_mode}" = "horizon" ]; then
|
||||
php "${ROOT}/artisan" horizon:status
|
||||
elif [ "${container_mode}" = "scheduler" ]; then
|
||||
if [ "$(supervisorctl status scheduler:scheduler_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||
exit 0
|
||||
else
|
||||
echo "Healthcheck failed."
|
||||
exit 1
|
||||
fi
|
||||
elif [ "${container_mode}" = "reverb" ]; then
|
||||
if [ "$(supervisorctl status reverb:reverb_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||
exit 0
|
||||
else
|
||||
echo "Healthcheck failed."
|
||||
exit 1
|
||||
fi
|
||||
elif [ "${container_mode}" = "worker" ]; then
|
||||
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||
exit 0
|
||||
else
|
||||
echo "Healthcheck failed."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Container mode mismatched."
|
||||
exit 1
|
||||
fi
|
||||
68
docker/prod/deployment/octane/FrankenPHP/Caddyfile
Normal file
68
docker/prod/deployment/octane/FrankenPHP/Caddyfile
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
{$CADDY_GLOBAL_OPTIONS}
|
||||
|
||||
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
||||
|
||||
frankenphp {
|
||||
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
||||
}
|
||||
|
||||
metrics {
|
||||
per_host
|
||||
}
|
||||
|
||||
servers {
|
||||
protocols h1
|
||||
}
|
||||
}
|
||||
|
||||
{$CADDY_EXTRA_CONFIG}
|
||||
|
||||
{$CADDY_SERVER_SERVER_NAME} {
|
||||
log {
|
||||
level WARN
|
||||
|
||||
format filter {
|
||||
wrap {$CADDY_SERVER_LOGGER}
|
||||
fields {
|
||||
uri query {
|
||||
replace authorization REDACTED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route {
|
||||
root * "{$APP_PUBLIC_PATH}"
|
||||
encode zstd br gzip
|
||||
|
||||
{$CADDY_SERVER_EXTRA_DIRECTIVES}
|
||||
|
||||
request_body {
|
||||
max_size 500MB
|
||||
}
|
||||
|
||||
@static {
|
||||
file
|
||||
path *.js *.css *.jpg *.jpeg *.webp *.weba *.webm *.gif *.png *.ico *.cur *.gz *.svg *.svgz *.mp4 *.mp3 *.ogg *.ogv *.htc *.woff2 *.woff
|
||||
}
|
||||
|
||||
@staticshort {
|
||||
file
|
||||
path *.json *.xml *.rss
|
||||
}
|
||||
|
||||
header @static Cache-Control "public, immutable, stale-while-revalidate, max-age=31536000"
|
||||
|
||||
header @staticshort Cache-Control "no-cache, max-age=3600"
|
||||
|
||||
@rejected `path('*.bak', '*.conf', '*.dist', '*.fla', '*.ini', '*.inc', '*.inci', '*.log', '*.orig', '*.psd', '*.sh', '*.sql', '*.swo', '*.swp', '*.swop', '*/.*') && !path('*/.well-known/*')`
|
||||
error @rejected 401
|
||||
|
||||
php_server {
|
||||
index frankenphp-worker.php
|
||||
try_files {path} frankenphp-worker.php
|
||||
resolve_root_symlink
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,65 @@
|
||||
[program:octane]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019
|
||||
; command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=localhost --port=443 --admin-port=2019 --https --http-redirect
|
||||
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
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
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
|
||||
user = %(ENV_USER)s
|
||||
priority = 1
|
||||
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
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan horizon
|
||||
user = %(ENV_USER)s
|
||||
priority = 3
|
||||
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
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
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
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
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
|
||||
|
||||
[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]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
files = /etc/supervisord.conf
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
version: '2.7'
|
||||
rpc:
|
||||
listen: 'tcp://127.0.0.1:6001'
|
||||
server:
|
||||
relay: pipes
|
||||
http:
|
||||
middleware: [ "static", "gzip", "headers" ]
|
||||
max_request_size: 20
|
||||
static:
|
||||
dir: "public"
|
||||
forbid: [ ".php", ".htaccess" ]
|
||||
uploads:
|
||||
forbid: [".php", ".exe", ".bat", ".sh"]
|
||||
pool:
|
||||
allocate_timeout: 10s
|
||||
destroy_timeout: 10s
|
||||
supervisor:
|
||||
max_worker_memory: 128
|
||||
exec_ttl: 60s
|
||||
logs:
|
||||
mode: production
|
||||
level: debug
|
||||
encoding: json
|
||||
status:
|
||||
address: localhost:2114
|
||||
@@ -1,50 +0,0 @@
|
||||
[program:octane]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8000 --rpc-port=6001 --rr-config=%(ENV_ROOT)s/.rr.yaml
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
environment=LARAVEL_OCTANE="1"
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan horizon
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_HORIZON)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
stopwaitsecs=3600
|
||||
|
||||
[program:scheduler]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=supercronic -overlapping /etc/supercronic/laravel
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
|
||||
[program:clear-scheduler-cache]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
startretries=1
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
@@ -1,50 +0,0 @@
|
||||
[program:octane]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan octane:start --server=swoole --host=0.0.0.0 --port=8000
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
environment=LARAVEL_OCTANE="1"
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan horizon
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_HORIZON)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
stopwaitsecs=3600
|
||||
|
||||
[program:scheduler]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=supercronic -overlapping /etc/supercronic/laravel
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
|
||||
[program:clear-scheduler-cache]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
startretries=1
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
@@ -1,30 +0,0 @@
|
||||
[PHP]
|
||||
post_max_size = 100M
|
||||
upload_max_filesize = 100M
|
||||
expose_php = 0
|
||||
realpath_cache_size = 16M
|
||||
realpath_cache_ttl = 360
|
||||
max_input_time = 5
|
||||
|
||||
[Opcache]
|
||||
opcache.enable = 1
|
||||
opcache.enable_cli = 1
|
||||
opcache.memory_consumption = 256M
|
||||
opcache.use_cwd = 0
|
||||
opcache.max_file_size = 0
|
||||
opcache.max_accelerated_files = 32531
|
||||
opcache.validate_timestamps = 0
|
||||
opcache.file_update_protection = 0
|
||||
opcache.interned_strings_buffer = 16
|
||||
opcache.file_cache = 60
|
||||
|
||||
[JIT]
|
||||
opcache.jit_buffer_size = 128M
|
||||
opcache.jit = disable
|
||||
opcache.jit_prof_threshold = 0.001
|
||||
opcache.jit_max_root_traces = 2048
|
||||
opcache.jit_max_side_traces = 256
|
||||
|
||||
[zlib]
|
||||
zlib.output_compression = On
|
||||
zlib.output_compression_level = 9
|
||||
@@ -5,6 +5,8 @@ expose_php = 0
|
||||
realpath_cache_size = 16M
|
||||
realpath_cache_ttl = 360
|
||||
max_input_time = 5
|
||||
register_argc_argv = 0
|
||||
date.timezone = ${TZ:-UTC}
|
||||
|
||||
[Opcache]
|
||||
opcache.enable = 1
|
||||
@@ -16,7 +18,6 @@ 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
|
||||
|
||||
@@ -4,41 +4,49 @@ set -e
|
||||
container_mode=${CONTAINER_MODE:-"http"}
|
||||
octane_server=${OCTANE_SERVER}
|
||||
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
|
||||
echo "Container mode: $container_mode"
|
||||
|
||||
initialStuff() {
|
||||
echo "Container mode: $container_mode"
|
||||
|
||||
if [ ${auto_db_migrate} = "true" ]; then
|
||||
echo "Auto database migration enabled."
|
||||
php artisan migrate --isolated --force
|
||||
fi
|
||||
|
||||
php artisan storage:link; \
|
||||
php artisan optimize:clear; \
|
||||
php artisan event:cache; \
|
||||
php artisan config:cache; \
|
||||
php artisan route:cache;
|
||||
php artisan optimize;
|
||||
}
|
||||
|
||||
if [ "$1" != "" ]; then
|
||||
exec "$@"
|
||||
elif [ ${container_mode} = "http" ]; then
|
||||
echo "Octane Server: $octane_server"
|
||||
elif [ "${container_mode}" = "http" ]; then
|
||||
initialStuff
|
||||
if [ ${octane_server} = "frankenphp" ]; then
|
||||
echo "Octane Server: $octane_server"
|
||||
if [ "${octane_server}" = "frankenphp" ]; then
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
|
||||
elif [ ${octane_server} = "swoole" ]; then
|
||||
elif [ "${octane_server}" = "swoole" ]; then
|
||||
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
|
||||
else
|
||||
echo "Invalid Octane server supplied."
|
||||
exit 1
|
||||
fi
|
||||
elif [ ${container_mode} = "horizon" ]; then
|
||||
elif [ "${container_mode}" = "horizon" ]; then
|
||||
initialStuff
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.horizon.conf
|
||||
elif [ ${container_mode} = "scheduler" ]; then
|
||||
elif [ "${container_mode}" = "reverb" ]; then
|
||||
initialStuff
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.reverb.conf
|
||||
elif [ "${container_mode}" = "scheduler" ]; then
|
||||
initialStuff
|
||||
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
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.worker.conf
|
||||
else
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=%(ENV_USER)s
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[unix_http_server]
|
||||
file=/var/run/supervisor.sock
|
||||
nodaemon = true
|
||||
user = %(ENV_USER)s
|
||||
logfile = /var/log/supervisor/supervisord.log
|
||||
pidfile = /var/run/supervisord.pid
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///var/run/supervisor.sock
|
||||
|
||||
[inet_http_server]
|
||||
port = 127.0.0.1:9001
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan horizon
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopwaitsecs=3600
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan horizon
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
stderr_logfile_maxbytes = 0
|
||||
stopwaitsecs = 3600
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
files = /etc/supervisord.conf
|
||||
|
||||
14
docker/prod/deployment/supervisord.reverb.conf
Normal file
14
docker/prod/deployment/supervisord.reverb.conf
Normal file
@@ -0,0 +1,14 @@
|
||||
[program:reverb]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
stderr_logfile_maxbytes = 0
|
||||
minfds = 10000
|
||||
|
||||
[include]
|
||||
files = /etc/supervisord.conf
|
||||
@@ -1,26 +1,26 @@
|
||||
[program:scheduler]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=supercronic -overlapping /etc/supercronic/laravel
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = supercronic -overlapping /etc/supercronic/laravel
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
stderr_logfile_maxbytes = 0
|
||||
|
||||
[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=true
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
startretries=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = false
|
||||
startsecs = 0
|
||||
startretries = 1
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
stderr_logfile_maxbytes = 0
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
files = /etc/supervisord.conf
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=%(ENV_USER)s
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:octane]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan octane:start --server=swoole --host=0.0.0.0 --port=8000
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
environment=LARAVEL_OCTANE="1"
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan horizon
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_HORIZON)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/horizon.log
|
||||
stopwaitsecs=3600
|
||||
|
||||
[program:scheduler]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=supercronic -overlapping /etc/supercronic/laravel
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/scheduler.log
|
||||
|
||||
[program:clear-scheduler-cache]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=false
|
||||
stdout_logfile=%(ENV_ROOT)s/scheduler.log
|
||||
@@ -1,13 +1,13 @@
|
||||
[program:worker]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=%(ENV_WORKER_COMMAND)s
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = %(ENV_WORKER_COMMAND)s
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stdout_logfile = /dev/stdout
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile = /dev/stderr
|
||||
stderr_logfile_maxbytes = 0
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
files = /etc/supervisord.conf
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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"
|
||||
@@ -102,7 +102,7 @@ test('test that updating billable rate works with existing time entries', async
|
||||
|
||||
await page.getByRole('row').first().getByRole('button').click();
|
||||
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||
await page.getByText('Non-Billable').click();
|
||||
await page.getByText('Non-Billable').click();
|
||||
await page.getByText('Custom Rate').click();
|
||||
await page
|
||||
.getByPlaceholder('Billable Rate')
|
||||
@@ -111,8 +111,8 @@ test('test that updating billable rate works with existing time entries', async
|
||||
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('button', { name: 'Yes, update existing time entries' })
|
||||
.click(),
|
||||
.locator('button').filter({ hasText: 'Yes, update existing time' })
|
||||
.click(),
|
||||
page.waitForRequest(
|
||||
async (request) =>
|
||||
request.url().includes('/projects/') &&
|
||||
|
||||
@@ -31,7 +31,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
|
||||
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry for ${projectName}`);
|
||||
|
||||
await page.getByRole('button', { name: 'No Project' }).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();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
|
||||
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry with tag ${tagName}`);
|
||||
|
||||
// Add tag
|
||||
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();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||
|
||||
// Set billable status
|
||||
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
|
||||
await goToReporting(page);
|
||||
await page.getByRole('button', { name: 'Project' }).nth(0).click();
|
||||
await page.getByText(project1).click();
|
||||
await page.getByRole('dialog').getByText(project1).click();
|
||||
|
||||
await Promise.all([
|
||||
// escape
|
||||
@@ -114,8 +114,8 @@ test('test that project filtering works in reporting', async ({ page }) => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify only project1 time entries are shown
|
||||
await expect(page.getByText(project1)).toBeVisible();
|
||||
await expect(page.getByText(project2)).not.toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that tag filtering works in reporting', async ({ page }) => {
|
||||
@@ -142,7 +142,7 @@ test('test that tag filtering works in reporting', async ({ page }) => {
|
||||
]);
|
||||
|
||||
// Verify only time entries with tag1 are shown
|
||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that billable status filtering works in reporting', async ({ page }) => {
|
||||
@@ -164,7 +164,7 @@ test('test that billable status filtering works in reporting', async ({ page })
|
||||
]);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||
@@ -45,6 +46,7 @@ return [
|
||||
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
|
||||
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
|
||||
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
||||
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||
],
|
||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||
];
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -16,7 +16,7 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -12,3 +12,7 @@ parameters:
|
||||
checkOctaneCompatibility: true
|
||||
checkModelProperties: 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#'
|
||||
|
||||
2052
public/fonts/Inter-Variable.ttf
Normal file
2052
public/fonts/Inter-Variable.ttf
Normal file
File diff suppressed because one or more lines are too long
2052
public/fonts/Inter-Variable.woff2
Normal file
2052
public/fonts/Inter-Variable.woff2
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user