Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
32abd2b61f Fixed problem with merge into when project members already exist in destination member 2025-03-10 13:46:57 +01:00
584 changed files with 5998 additions and 22725 deletions

View File

@@ -10,8 +10,6 @@ on:
- '.github/workflows/build-private.yml'
- 'docker/prod/**'
workflow_dispatch:
permissions:
contents: read
name: Build - Private
jobs:
@@ -19,7 +17,6 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: "Check out code"
uses: actions/checkout@v4
@@ -110,24 +107,6 @@ jobs:
- name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
uses: php-actions/composer@v6
with:
working_dir: "extensions/Invoicing"
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
@@ -148,9 +127,6 @@ jobs:
- name: "Activate services extension"
run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install npm dependencies"
run: npm ci

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ class CreateNewUser implements CreatesNewUsers
/**
* Create a newly registered user.
*
* @param array<string, mixed> $input
* @param array<string, string> $input
*
* @throws ValidationException
*/
@@ -76,11 +76,6 @@ class CreateNewUser implements CreatesNewUsers
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
$startOfWeek = Weekday::Monday;
$numberFormat = null;
$currencyFormat = null;
$dateFormat = null;
$intervalFormat = null;
$timeFormat = null;
$currency = null;
if ($ipLookupResponse !== null) {
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
@@ -90,7 +85,7 @@ class CreateNewUser implements CreatesNewUsers
$currency = $ipLookupResponse->currency;
}
$user = null;
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat): void {
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency): void {
$userService = app(UserService::class);
$user = $userService->createUser(
$input['name'],
@@ -98,12 +93,7 @@ class CreateNewUser implements CreatesNewUsers
$input['password'],
$timezone ?? 'UTC',
$startOfWeek,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat
$currency ?? 'EUR',
);
});

View File

@@ -6,6 +6,7 @@ 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;
@@ -58,7 +59,8 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
$user->updateProfilePhoto($input['photo']);
}
if ($input['email'] !== $user->email) {
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],

View File

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

View File

@@ -4,11 +4,10 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Models\Organization;
use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
@@ -34,18 +33,16 @@ class CreateOrganization implements CreatesTeams
'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createTeam');
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
$organization = new Organization;
$organization->name = $input['name'];
$organization->personal_team = false;
$organization->owner()->associate($user);
$organization->save();
$currency = null;
if ($ipLookupResponse !== null) {
$currency = $ipLookupResponse->currency;
}
$organization = app(OrganizationService::class)->createOrganization(
$input['name'],
$user,
false,
$currency
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
$user->switchTeam($organization);

View File

@@ -64,8 +64,8 @@ class UserCreateCommand extends Command
$password,
'UTC',
Weekday::Monday,
null,
verifyEmail: $verifyEmail
'EUR',
$verifyEmail
);
});
/** @var Organization|null $organization */

View File

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

View File

@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SelfHostDatabaseConsistency extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:database-consistency';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$hadAProblem = false;
// Task need to be part of project in time entries
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
->get();
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
// Client id is the client id of the project
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('projects', 'time_entries.project_id', '=', 'projects.id')
->where(DB::raw('coalesce(projects.client_id::varchar, \'\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \'\')'))
->get();
$this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem);
// Client id can only be not null if the project id is not null
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->whereNotNull('client_id')
->whereNull('project_id')
->get();
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
// Every user needs to be a member of at least one organization
$problems = DB::table('users')
->select(['users.id as id'])
->leftJoin('members', 'users.id', '=', 'members.user_id')
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Users are not member of any organization', $hadAProblem);
// Every organization needs at least an owner
$problems = DB::table('organizations')
->select(['organizations.id as id'])
->leftJoin('members', function (JoinClause $join): void {
$join->on('organizations.id', '=', 'members.organization_id')
->where('members.role', '=', 'owner');
})
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Organizations without an owner', $hadAProblem);
// Every member can only have one running time entry
$problems = DB::table('time_entries')
->select(['user_id as id'])
->whereNull('end')
->groupBy('user_id')
->havingRaw('count(*) > 1')
->get(['user_id', DB::raw('count(*) as count')]);
$this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem);
// Users have a current organization that they are not a member of
$problems = DB::table('users')
->select(['users.id as id'])
->whereNotNull('current_team_id')
->whereNotIn('current_team_id', function (Builder $query): void {
$query->select('organization_id')
->from('members')
->whereColumn('members.user_id', 'users.id');
})->get();
$this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem);
return $hadAProblem ? self::FAILURE : self::SUCCESS;
}
/**
* @param Collection<int, \stdClass> $problems
*/
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
{
$message = 'Consistency problem: '.$message;
if ($problems->isNotEmpty()) {
$ids = $problems->pluck('id');
$hadAProblem = true;
Log::error($message, [
'ids' => $ids,
]);
$error = $message;
foreach ($ids as $id) {
$error .= "\n - ".$id;
}
$this->error($error);
}
}
}

View File

@@ -18,10 +18,6 @@ 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();
@@ -29,10 +25,6 @@ class Kernel extends ConsoleKernel
$schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily();
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->everySixHours();
}
/**

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum CurrencyFormat: string
{
use LaravelEnumHelper;
case ISOCodeBeforeWithSpace = 'iso-code-before-with-space';
case ISOCodeAfterWithSpace = 'iso-code-after-with-space';
case SymbolBefore = 'symbol-before';
case SymbolAfter = 'symbol-after';
case SymbolBeforeWithSpace = 'symbol-before-with-space';
case SymbolAfterWithSpace = 'symbol-after-with-space';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.currency_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum DateFormat: string
{
use LaravelEnumHelper;
case PointSeparatedDMYYYY = 'point-separated-d-m-yyyy';
case SlashSeparatedMMDDYYYY = 'slash-separated-mm-dd-yyyy';
case SlashSeparatedDDMMYYYY = 'slash-separated-dd-mm-yyyy';
case HyphenSeparatedDDMMYYY = 'hyphen-separated-dd-mm-yyyy';
case HyphenSeparatedMMDDDYYYY = 'hyphen-separated-mm-dd-yyyy';
case HyphenSeparatedYYYYMMDD = 'hyphen-separated-yyyy-mm-dd';
public function toCarbonFormat(): string
{
return match ($this->value) {
self::PointSeparatedDMYYYY->value => 'j.n.Y',
self::SlashSeparatedMMDDYYYY->value => 'm/d/Y',
self::SlashSeparatedDDMMYYYY->value => 'd/m/Y',
self::HyphenSeparatedDDMMYYY->value => 'd-m-Y',
self::HyphenSeparatedMMDDDYYYY->value => 'm-d-Y',
self::HyphenSeparatedYYYYMMDD->value => 'Y-m-d',
};
}
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.date_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum IntervalFormat: string
{
use LaravelEnumHelper;
case Decimal = 'decimal';
case HoursMinutes = 'hours-minutes';
case HoursMinutesColonSeparated = 'hours-minutes-colon-separated';
case HoursMinutesSecondsColonSeparated = 'hours-minutes-seconds-colon-separated';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.interval_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
/**
* @info https://en.wikipedia.org/wiki/Decimal_separator
*/
enum NumberFormat: string
{
use LaravelEnumHelper;
case ThousandsPointDecimalComma = 'point-comma';
case ThousandsCommaDecimalPoint = 'comma-point';
case ThousandsSpaceDecimalComma = 'space-comma';
case ThousandsSpaceDecimalPoint = 'space-point';
case ThousandsApostropheDecimalPoint = 'apostrophe-point';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.number_format.'.$value);
}
return $selectArray;
}
}

View File

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

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeFormat: string
{
use LaravelEnumHelper;
case TwelveHours = '12-hours';
case TwentyFourHours = '24-hours';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.time_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederAfterSeed
{
use Dispatchable;
public function __construct() {}
}

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederBeforeDelete
{
use Dispatchable;
public function __construct() {}
}

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
use Novadaemon\FilamentPrettyJson\PrettyJson;
class AuditResource extends Resource
{
@@ -38,8 +38,8 @@ class AuditResource extends Resource
->maxLength(255),
Forms\Components\TextInput::make('auditable_id')
->required(),
PrettyJsonField::make('old_values'),
PrettyJsonField::make('new_values'),
PrettyJson::make('old_values'),
PrettyJson::make('new_values'),
Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent')

View File

@@ -15,13 +15,12 @@ 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;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
use Novadaemon\FilamentPrettyJson\PrettyJson;
/**
* @source https://gitlab.com/amvisor/filament-failed-jobs
@@ -51,7 +50,7 @@ class FailedJobResource extends Resource
// make text a little bit smaller because often a complete Stack Trace is shown:
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
PrettyJson::make('payload')->disabled()->columnSpan(4),
])->columns(4);
}
@@ -76,8 +75,7 @@ class FailedJobResource extends Resource
->filters([])
->bulkActions([
BulkAction::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry selected')
->label('Retry')
->requiresConfirmation()
->action(function (Collection $records): void {
/** @var FailedJob $record */
@@ -89,13 +87,11 @@ class FailedJobResource extends Resource
->success()
->send();
}),
DeleteBulkAction::make(),
])
->actions([
DeleteAction::make(),
ViewAction::make(),
DeleteAction::make('Delete'),
ViewAction::make('View'),
Action::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry')
->requiresConfirmation()
->action(function (FailedJob $record): void {
@@ -113,6 +109,7 @@ class FailedJobResource extends Resource
return [
'index' => ListFailedJobs::route('/'),
'view' => ViewFailedJobs::route('/{record}'),
];
}
}

View File

@@ -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,8 +19,7 @@ class ListFailedJobs extends ListRecords
{
return [
Action::make('retry_all')
->icon('heroicon-o-arrow-path')
->label('Retry all')
->label('Retry all failed Jobs')
->requiresConfirmation()
->action(function (): void {
Artisan::call('queue:retry all');
@@ -31,8 +30,7 @@ class ListFailedJobs extends ListRecords
}),
Action::make('delete_all')
->icon('heroicon-o-trash')
->label('Delete all')
->label('Delete all failed Jobs')
->requiresConfirmation()
->color('danger')
->action(function (): void {

View File

@@ -4,11 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
@@ -61,21 +56,6 @@ class OrganizationResource extends Resource
->searchable(['name', 'email'])
->disabledOn(['edit'])
->required(),
Select::make('date_format')
->options(DateFormat::toSelectArray())
->required(),
Select::make('currency_format')
->options(CurrencyFormat::toSelectArray())
->required(),
Select::make('interval_format')
->options(IntervalFormat::toSelectArray())
->required(),
Select::make('number_format')
->options(NumberFormat::toSelectArray())
->required(),
Select::make('time_format')
->options(TimeFormat::toSelectArray())
->required(),
Forms\Components\Select::make('currency')
->label('Currency')
->options(function (): array {

View File

@@ -18,7 +18,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
use Novadaemon\FilamentPrettyJson\PrettyJson;
class ReportResource extends Resource
{
@@ -58,7 +58,7 @@ class ReportResource extends Resource
Forms\Components\TextInput::make('share_secret')
->label('Share Secret')
->nullable(),
PrettyJsonField::make('properties')
PrettyJson::make('properties')
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
return $record->getRawOriginal('properties');
})

View File

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

View File

@@ -23,7 +23,6 @@ use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -208,14 +207,6 @@ class UserResource extends Resource
}),
])
->bulkActions([
Tables\Actions\BulkAction::make('Resend verification email')
->icon('heroicon-o-paper-airplane')
->action(function (Collection $records): void {
foreach ($records as $user) {
/** @var User $user */
$user->sendEmailVerificationNotification();
}
}),
]);
}

View File

@@ -24,7 +24,7 @@ class CreateUser extends CreateRecord
$data['timezone'],
Weekday::from($data['week_start']),
$data['currency'],
verifyEmail: (bool) $data['is_email_verified']
(bool) $data['is_email_verified']
);
return $user;

View File

@@ -8,12 +8,9 @@ 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
{
@@ -31,10 +28,7 @@ class ApiTokenController extends Controller
$user = $this->user();
$tokens = $user->tokens()
->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
})
->where('client_id', '=', config('passport.personal_access_client.id'))
->get();
return new ApiTokenCollection($tokens);
@@ -54,21 +48,15 @@ class ApiTokenController extends Controller
{
$user = $this->user();
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;
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->token;
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
}
/**
@@ -83,10 +71,13 @@ class ApiTokenController extends Controller
{
$user = $this->user();
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?->hasGrantType('personal_access') ?? false)) {
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token');
}
@@ -106,10 +97,13 @@ 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?->hasGrantType('personal_access') ?? false)) {
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token');
}

View File

@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Models\Organization;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class ChartController extends Controller
{
/**
* @throws AuthorizationException
*
* @operationId weeklyProjectOverview
*
* @response array<int, array{value: int, name: string, color: string}>
*/
public function weeklyProjectOverview(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
return response()->json($weeklyProjectOverview);
}
/**
* @throws AuthorizationException
*
* @operationId latestTasks
*
* @response array<int, array{task_id: string, name: string, description: string|null, status: bool, time_entry_id: string|null}>
*/
public function latestTasks(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$latestTasks = $dashboardService->latestTasks($user, $organization);
return response()->json($latestTasks);
}
/**
* @throws AuthorizationException
*
* @operationId lastSevenDays
*
* @response array<int, array{ date: string, duration: int, history: array<int> }>
*/
public function lastSevenDays(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
return response()->json($lastSevenDays);
}
/**
* @throws AuthorizationException
*
* @operationId latestTeamActivity
*
* @response array<int, array{member_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>
*/
public function latestTeamActivity(Organization $organization, DashboardService $dashboardService, PermissionStore $permissionStore): JsonResponse
{
$this->checkPermission($organization, 'charts:view:all');
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
return response()->json($latestTeamActivity);
}
/**
* @throws AuthorizationException
*
* @operationId dailyTrackedHours
*
* @response array<int, array{date: string, duration: int}>
*/
public function dailyTrackedHours(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
return response()->json($dailyTrackedHours);
}
/**
* @throws AuthorizationException
*
* @operationId totalWeeklyTime
*
* @response int
*/
public function totalWeeklyTime(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
return response()->json($totalWeeklyTime);
}
/**
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableTime
*
* @response int
*/
public function totalWeeklyBillableTime(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
return response()->json($totalWeeklyBillableTime);
}
/**
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableAmount
*
* @response array{value: int, currency: string}
*/
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
if (! $showBillableRate) {
throw new AuthorizationException('You do not have permission to view billable rates.');
}
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
return response()->json($totalWeeklyBillableAmount);
}
/**
* @throws AuthorizationException
*
* @operationId weeklyHistory
*
* @response array<int, array{date: string, duration: int}>
*/
public function weeklyHistory(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
return response()->json($weeklyHistory);
}
}

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Service\CurrencyService;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\JsonResponse;
class CurrencyController extends Controller
{
/**
* Get all currencies
*
* @response array{code: string, name: string, symbol: string}[]
*
* @operationId getCurrencies
*/
public function index(): JsonResponse
{
$currencyService = app(CurrencyService::class);
$currencies = array_values(array_map(
fn (Currency $currency): array => [
'code' => $currency->getCurrencyCode(),
'name' => $currency->getName(),
'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()),
],
ISOCurrencyProvider::getInstance()->getAvailableCurrencies()
));
return response()->json($currencies);
}
}

View File

@@ -4,7 +4,6 @@ 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;
@@ -51,7 +50,6 @@ class InvitationController extends Controller
*
* @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws InvitationForTheEmailAlreadyExistsApiException
*
* @operationId invite
*/

View File

@@ -10,14 +10,12 @@ 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;
@@ -102,13 +100,11 @@ class MemberController extends Controller
*
* @operationId removeMember
*/
public function destroy(MemberDestroyRequest $request, Organization $organization, Member $member, MemberService $memberService): JsonResponse
public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
$this->checkPermission($organization, 'members:delete', $member);
$deleteRelated = $request->getDeleteRelated();
$memberService->removeMember($member, $organization, $deleteRelated);
$memberService->removeMember($member, $organization);
return response()
->json(null, 204);
@@ -118,8 +114,6 @@ class MemberController extends Controller
* Make a member a placeholder member
*
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed
*
* @operationId makePlaceholder
*/
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
@@ -140,8 +134,6 @@ class MemberController extends Controller
}
/**
* Merge one member into another
*
* @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws \Throwable
@@ -174,7 +166,6 @@ class MemberController extends Controller
* @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
* @throws InvitationForTheEmailAlreadyExistsApiException
*
* @operationId invitePlaceholder
*/

View File

@@ -40,35 +40,15 @@ class OrganizationController extends Controller
{
$this->checkPermission($organization, 'organizations:update');
if ($request->getName() !== null) {
$organization->name = $request->getName();
}
if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
}
if ($request->getNumberFormat() !== null) {
$organization->number_format = $request->getNumberFormat();
}
if ($request->getCurrencyFormat() !== null) {
$organization->currency_format = $request->getCurrencyFormat();
}
if ($request->getDateFormat() !== null) {
$organization->date_format = $request->getDateFormat();
}
if ($request->getIntervalFormat() !== null) {
$organization->interval_format = $request->getIntervalFormat();
}
if ($request->getTimeFormat() !== null) {
$organization->time_format = $request->getTimeFormat();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;
$organization->billable_rate = $request->getBillableRate();
$organization->name = $request->input('name');
$oldBillableRate = $organization->billable_rate;
if ($request->has('employees_can_see_billable_rates')) {
$organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates');
}
$organization->billable_rate = $request->getBillableRate();
$organization->save();
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
}

View File

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

View File

@@ -107,8 +107,6 @@ 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();

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
@@ -27,13 +26,11 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\LocalizationService;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
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;
@@ -48,7 +45,6 @@ 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;
@@ -142,15 +138,8 @@ 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);
@@ -191,9 +180,6 @@ 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([
@@ -206,7 +192,6 @@ class TimeEntryController extends Controller
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
$localizationService = LocalizationService::forOrganization($organization);
if ($format === ExportFormat::CSV) {
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone);
$export->export();
@@ -218,19 +203,15 @@ class TimeEntryController extends Controller
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
$timeEntriesQuery->clone()->reorder()->withOnly([]),
null,
null,
$user->timezone,
$user->week_start,
false,
null,
null,
$showBillableRate,
$roundingType,
$roundingMinutes,
null
);
$html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(),
@@ -239,8 +220,6 @@ class TimeEntryController extends Controller
'currency' => $organization->currency,
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) {
@@ -275,7 +254,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone, $localizationService),
new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
@@ -306,18 +285,18 @@ class TimeEntryController extends Controller
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int|null,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int|null,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int|null
* cost: int
* }
* }
*
@@ -333,13 +312,10 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all');
}
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
@@ -349,10 +325,7 @@ class TimeEntryController extends Controller
$user->week_start,
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd(),
$showBillableRate,
$roundingType,
$roundingMinutes
$request->getEnd()
);
return [
@@ -386,13 +359,10 @@ class TimeEntryController extends Controller
}
$debug = $request->getDebug();
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(),
@@ -402,10 +372,7 @@ class TimeEntryController extends Controller
$user->week_start,
false,
$request->getStart(),
$request->getEnd(),
$showBillableRate,
$roundingType,
$roundingMinutes
$request->getEnd()
);
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(),
@@ -415,14 +382,10 @@ class TimeEntryController extends Controller
$user->week_start,
true,
$request->getStart(),
$request->getEnd(),
$showBillableRate,
$roundingType,
$roundingMinutes
$request->getEnd()
);
$currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
$localizationService = LocalizationService::forOrganization($organization);
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports';
@@ -448,12 +411,9 @@ class TimeEntryController extends Controller
'currency' => $currency,
'group' => $group,
'subGroup' => $subGroup,
'timezone' => $timezone,
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
'debug' => $debug,
'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) {
@@ -482,7 +442,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
@@ -501,7 +461,7 @@ class TimeEntryController extends Controller
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization');

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\User\UserResource;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Resources\Json\JsonResource;
class UserController extends Controller
{
@@ -18,7 +19,7 @@ class UserController extends Controller
*
* @throws AuthorizationException
*/
public function me(): UserResource
public function me(): JsonResource
{
$user = $this->user();

View File

@@ -43,10 +43,7 @@ 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', [
'user' => $user->getKey(),
'organization' => $organization->getKey(),
]);
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
throw new AuthorizationException;
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
@@ -20,14 +19,30 @@ class DashboardController extends Controller
{
$user = $this->user();
$organization = $this->currentOrganization();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
$latestTasks = $dashboardService->latestTasks($user, $organization);
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
$latestTeamActivity = null;
if ($permissionStore->has($organization, 'time-entries:view:all')) {
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
}
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return Inertia::render('Dashboard');
return Inertia::render('Dashboard', [
'weeklyProjectOverview' => $weeklyProjectOverview,
'latestTasks' => $latestTasks,
'lastSevenDays' => $lastSevenDays,
'latestTeamActivity' => $latestTeamActivity,
'dailyTrackedHours' => $dailyTrackedHours,
'totalWeeklyTime' => $totalWeeklyTime,
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
'weeklyHistory' => $weeklyHistory,
]);
}
}

View File

@@ -5,6 +5,7 @@ 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;
@@ -19,7 +20,8 @@ class EnsureEmailIsVerified
{
if (! app()->isLocal()) {
if ($request->user() === null ||
(! $request->user()->hasVerifiedEmail())) {
($request->user() instanceof MustVerifyEmail &&
! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson()
? abort(403, 'Your email address is not verified.')
: Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));

View File

@@ -40,7 +40,6 @@ class HandleInertiaRequests extends Middleware
public function share(Request $request): array
{
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
@@ -49,8 +48,7 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'billing' => $currentOrganization !== null ? [
'billing' => $billing !== null && $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),

View File

@@ -26,7 +26,7 @@ class ShareInertiaData
{
/** @var PermissionStore $permissions */
$permissions = app(PermissionStore::class);
Inertia::share([
Inertia::share(array_filter([
'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);
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ApiToken;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Foundation\Http\FormRequest;
class ApiTokenStoreRequest extends BaseFormRequest
class ApiTokenStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1;
use Illuminate\Foundation\Http\FormRequest;
class BaseFormRequest extends FormRequest
{
/**
* @return list<string>
*/
protected function moneyRules(bool $bigInt = false): array
{
$rules = [
'integer',
'min:0',
];
if ($bigInt) {
$rules[] = 'max:9223372036854775807';
} else {
$rules[] = 'max:2147483647';
}
return $rules;
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ClientIndexRequest extends BaseFormRequest
class ClientIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ClientStoreRequest extends BaseFormRequest
class ClientStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Client|null $client Client from model binding
*/
class ClientUpdateRequest extends BaseFormRequest
class ClientUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Import;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ImportRequest extends BaseFormRequest
class ImportRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Invitation;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization
*/
class InvitationIndexRequest extends BaseFormRequest
class InvitationIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,15 +5,18 @@ declare(strict_types=1);
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\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization
*/
class InvitationStoreRequest extends BaseFormRequest
class InvitationStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -26,6 +29,10 @@ 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',

View File

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

View File

@@ -4,14 +4,14 @@ 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;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization
*/
class MemberIndexRequest extends BaseFormRequest
class MemberIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class MemberMergeIntoRequest extends BaseFormRequest
class MemberMergeIntoRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,15 +5,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization
*/
class MemberUpdateRequest extends BaseFormRequest
class MemberUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -27,12 +27,12 @@ class MemberUpdateRequest extends BaseFormRequest
'string',
Rule::enum(Role::class),
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
];
}

View File

@@ -4,98 +4,44 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Organization;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Validation\Rule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class OrganizationUpdateRequest extends BaseFormRequest
class OrganizationUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'employees_can_see_billable_rates' => [
'boolean',
],
'number_format' => [
Rule::enum(NumberFormat::class),
],
'currency_format' => [
Rule::enum(CurrencyFormat::class),
],
'date_format' => [
Rule::enum(DateFormat::class),
],
'interval_format' => [
Rule::enum(IntervalFormat::class),
],
'time_format' => [
Rule::enum(TimeFormat::class),
],
];
}
public function getName(): ?string
{
return $this->has('name') ? (string) $this->input('name') : null;
}
public function getNumberFormat(): ?NumberFormat
{
return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null;
}
public function getCurrencyFormat(): ?CurrencyFormat
{
return $this->has('currency_format') ? CurrencyFormat::from($this->input('currency_format')) : null;
}
public function getDateFormat(): ?DateFormat
{
return $this->has('date_format') ? DateFormat::from($this->input('date_format')) : null;
}
public function getIntervalFormat(): ?IntervalFormat
{
return $this->has('interval_format') ? IntervalFormat::from($this->input('interval_format')) : null;
}
public function getTimeFormat(): ?TimeFormat
{
return $this->has('time_format') ? TimeFormat::from($this->input('time_format')) : null;
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
public function getEmployeesCanSeeBillableRates(): ?bool
{
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProjectIndexRequest extends BaseFormRequest
class ProjectIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,21 +4,20 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectStoreRequest extends BaseFormRequest
class ProjectStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -28,7 +27,6 @@ class ProjectStoreRequest extends BaseFormRequest
public function rules(): array
{
return [
// Name of the project, the name needs to be unique per client and organization
'name' => [
'required',
'string',
@@ -36,13 +34,7 @@ class ProjectStoreRequest extends BaseFormRequest
'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$clientId = $this->input('client_id');
if (! is_string($clientId) || ! Str::isUuid($clientId)) {
$clientId = null;
}
return $builder->whereBelongsTo($this->organization, 'organization')
->where('client_id', $clientId);
return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.project_name_already_exists'),
],
'color' => [
@@ -55,15 +47,14 @@ class ProjectStoreRequest extends BaseFormRequest
'required',
'boolean',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
// ID of the client
'client_id' => [
'present',
'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */

View File

@@ -4,14 +4,13 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -19,7 +18,7 @@ use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
* @property Organization $organization Organization from model binding
* @property Project|null $project Project from model binding
*/
class ProjectUpdateRequest extends BaseFormRequest
class ProjectUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -35,13 +34,7 @@ class ProjectUpdateRequest extends BaseFormRequest
'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$clientId = $this->input('client_id');
if (! is_string($clientId) || ! Str::isUuid($clientId)) {
$clientId = null;
}
return $builder->whereBelongsTo($this->organization, 'organization')
->where('client_id', $clientId);
return $builder->whereBelongsTo($this->organization, 'organization');
})->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
],
'color' => [
@@ -61,18 +54,18 @@ class ProjectUpdateRequest extends BaseFormRequest
'boolean',
],
'client_id' => [
'present',
'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
'billable_rate' => array_merge([
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
$this->moneyRules()
),
// Estimated time in seconds
'estimated_time' => [
'nullable',

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectMemberStoreRequest extends BaseFormRequest
class ProjectMemberStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -31,12 +31,12 @@ class ProjectMemberStoreRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
];
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectMemberUpdateRequest extends BaseFormRequest
class ProjectMemberUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -21,12 +21,12 @@ class ProjectMemberUpdateRequest extends BaseFormRequest
public function rules(): array
{
return [
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
];
}

View File

@@ -6,19 +6,18 @@ 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;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class ReportStoreRequest extends BaseFormRequest
class ReportStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -41,7 +40,7 @@ class ReportStoreRequest extends BaseFormRequest
'required',
'boolean',
],
// After this date the report will be automatically set to private (is_public=false) (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
// After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone)
'public_until' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
@@ -129,18 +128,6 @@ 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',
],
];
}
@@ -218,22 +205,4 @@ 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');
}
}

View File

@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
/**
* @property Organization $organization Organization from model binding
*/
class ReportUpdateRequest extends BaseFormRequest
class ReportUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TagStoreRequest extends BaseFormRequest
class TagStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Tag|null $tag Tag from model binding
*/
class TagUpdateRequest extends BaseFormRequest
class TagUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Project;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TaskIndexRequest extends BaseFormRequest
class TaskIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TaskStoreRequest extends BaseFormRequest
class TaskStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Task|null $task Task from model binding
*/
class TaskUpdateRequest extends BaseFormRequest
class TaskUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -7,8 +7,6 @@ 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;
use App\Models\Organization;
@@ -18,6 +16,7 @@ use App\Models\Task;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -25,7 +24,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateExportRequest extends BaseFormRequest
class TimeEntryAggregateExportRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -165,18 +164,6 @@ 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',
],
];
}
@@ -224,22 +211,4 @@ 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');
}
}

View File

@@ -5,8 +5,6 @@ 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;
use App\Models\Organization;
@@ -16,6 +14,7 @@ use App\Models\Task;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -23,7 +22,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateRequest extends BaseFormRequest
class TimeEntryAggregateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -147,18 +146,6 @@ 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',
],
];
}
@@ -186,22 +173,4 @@ class TimeEntryAggregateRequest extends BaseFormRequest
{
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryDestroyMultipleRequest extends BaseFormRequest
class TimeEntryDestroyMultipleRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,7 +5,6 @@ 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;
@@ -134,18 +133,6 @@ 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',
],
];
}
@@ -183,22 +170,4 @@ 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');
}
}

View File

@@ -4,29 +4,26 @@ 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;
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 Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryIndexRequest extends BaseFormRequest
class TimeEntryIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|RuleContract>>
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
@@ -139,18 +136,6 @@ 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',
],
];
}
@@ -168,22 +153,4 @@ 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');
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -12,12 +11,13 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryStoreRequest extends BaseFormRequest
class TimeEntryStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -59,12 +59,12 @@ class TimeEntryStoreRequest extends BaseFormRequest
->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
],
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
// Start of time entry (ISO 8601 format, UTC timezone)
'start' => [
'required',
'date_format:Y-m-d\TH:i:s\Z',
],
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
// End of time entry (ISO 8601 format, UTC timezone)
'end' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -12,12 +11,13 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryUpdateMultipleRequest extends BaseFormRequest
class TimeEntryUpdateMultipleRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -12,12 +11,13 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryUpdateRequest extends BaseFormRequest
class TimeEntryUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -59,11 +59,11 @@ class TimeEntryUpdateRequest extends BaseFormRequest
->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
],
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
// Start of time entry (ISO 8601 format, UTC timezone)
'start' => [
'date_format:Y-m-d\TH:i:s\Z',
],
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
// End of time entry (ISO 8601 format, UTC timezone)
'end' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -12,10 +12,6 @@ abstract class BaseResource extends JsonResource
protected function formatDateTime(?Carbon $carbon): ?string
{
return $carbon?->toIso8601ZuluString();
}
protected function formatDate(?Carbon $carbon): ?string
{
return $carbon?->format('Y-m-d');
}
}

View File

@@ -4,14 +4,8 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Organization;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Resources\V1\BaseResource;
use App\Models\Organization;
use App\Service\CurrencyService;
use Illuminate\Http\Request;
/**
@@ -40,8 +34,6 @@ class OrganizationResource extends BaseResource
*/
public function toArray(Request $request): array
{
$currencyService = app(CurrencyService::class);
return [
/** @var string $id ID */
'id' => $this->resource->id,
@@ -55,18 +47,6 @@ class OrganizationResource extends BaseResource
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),
/** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->number_format->value,
/** @var CurrencyFormat $currency_format Currency format */
'currency_format' => $this->resource->currency_format->value,
/** @var DateFormat $date_format Date format */
'date_format' => $this->resource->date_format->value,
/** @var IntervalFormat $interval_format Interval format */
'interval_format' => $this->resource->interval_format->value,
/** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->time_format->value,
];
}
}

View File

@@ -8,11 +8,15 @@ 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);

View File

@@ -30,7 +30,7 @@ class DetailedReportResource extends BaseResource
/** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until),
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
'properties' => [
@@ -41,9 +41,9 @@ class DetailedReportResource extends BaseResource
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start),
'start' => $this->resource->properties->start->toIso8601ZuluString(),
/** @var string $end End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end),
'end' => $this->resource->properties->end->toIso8601ZuluString(),
/** @var bool|null $active Whether the report is active */
'active' => $this->resource->properties->active,
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
@@ -58,15 +58,11 @@ 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),
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
/** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
];
}
}

View File

@@ -4,14 +4,8 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use App\Service\CurrencyService;
use Illuminate\Http\Request;
/**
@@ -24,20 +18,20 @@ use Illuminate\Http\Request;
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int|null,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int|null,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int|null
* cost: int
* }
*/
class DetailedWithDataReportResource extends BaseResource
@@ -70,29 +64,15 @@ class DetailedWithDataReportResource extends BaseResource
*/
public function toArray(Request $request): array
{
$currencyService = app(CurrencyService::class);
return [
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until),
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency,
/** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->organization->number_format->value,
/** @var CurrencyFormat $currency_format Currency format */
'currency_format' => $this->resource->organization->currency_format->value,
/** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->organization->currency),
/** @var DateFormat $date_format Date format */
'date_format' => $this->resource->organization->date_format->value,
/** @var IntervalFormat $interval_format Interval format */
'interval_format' => $this->resource->organization->interval_format->value,
/** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->organization->time_format->value,
'properties' => [
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,
@@ -101,9 +81,9 @@ class DetailedWithDataReportResource extends BaseResource
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start),
'start' => $this->resource->properties->start->toIso8601ZuluString(),
/** @var string $end End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end),
'end' => $this->resource->properties->end->toIso8601ZuluString(),
],
/** @var array{
* grouped_type: string|null,

View File

@@ -30,13 +30,13 @@ class ReportResource extends BaseResource
/** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until),
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
/** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
/** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<int, ProjectMember> $projectMembers
* @property-read Collection<int, TimeEntry> $timeEntries
* @property-read Collection<ProjectMember> $projectMembers
* @property-read Collection<TimeEntry> $timeEntries
*
* @method static MemberFactory factory()
*/
@@ -47,7 +47,7 @@ class Member extends JetstreamMembership implements AuditableContract
protected $table = 'members';
/**
* @return BelongsTo<User, $this>
* @return BelongsTo<User, Member>
*/
public function user(): BelongsTo
{
@@ -55,7 +55,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, Member>
*/
public function organization(): BelongsTo
{
@@ -63,7 +63,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return HasMany<TimeEntry, $this>
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{
@@ -71,7 +71,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return HasMany<ProjectMember, $this>
* @return HasMany<ProjectMember>
*/
public function projectMembers(): HasMany
{

View File

@@ -4,11 +4,6 @@ declare(strict_types=1);
namespace App\Models;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationFactory;
@@ -18,12 +13,12 @@ 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;
use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -42,13 +37,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Collection<int, User> $realUsers
* @property-read Collection<int, OrganizationInvitation> $teamInvitations
* @property Member $membership
* @property NumberFormat $number_format
* @property CurrencyFormat $currency_format
* @property DateFormat $date_format
* @property IntervalFormat $interval_format
* @property TimeFormat $time_format
*
* @method HasMany<OrganizationInvitation, $this> teamInvitations()
* @method HasMany<OrganizationInvitation> teamInvitations()
* @method static OrganizationFactory factory()
*/
class Organization extends JetstreamTeam implements AuditableContract
@@ -70,17 +60,12 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team' => 'boolean',
'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class,
'interval_format' => IntervalFormat::class,
'time_format' => TimeFormat::class,
];
/**
* The attributes that are mass assignable.
*
* @var list<string>
* @var array<int, string>
*/
protected $fillable = [
'name',
@@ -104,6 +89,7 @@ class Organization extends JetstreamTeam implements AuditableContract
* @var array<string, mixed>
*/
protected $attributes = [
'currency' => 'EUR',
];
/**
@@ -126,7 +112,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* Get all the users that belong to the team.
*
* @return BelongsToMany<User, $this, Pivot, 'membership'>
* @return BelongsToMany<User>
*/
public function users(): BelongsToMany
{
@@ -143,7 +129,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* Get the owner of the team.
*
* @return BelongsTo<User, $this>
* @return BelongsTo<User, Organization>
*/
public function owner(): BelongsTo
{
@@ -151,7 +137,7 @@ class Organization extends JetstreamTeam implements AuditableContract
}
/**
* @return HasMany<Member, $this>
* @return HasMany<Member>
*/
public function members(): HasMany
{
@@ -159,7 +145,7 @@ class Organization extends JetstreamTeam implements AuditableContract
}
/**
* @return BelongsToMany<User, $this, Pivot, 'membership'>
* @return BelongsToMany<User>
*/
public function realUsers(): BelongsToMany
{

View File

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

View File

@@ -4,26 +4,6 @@ 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;
/**
* @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');
}
}
class AuthCode extends PassportAuthCode {}

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