Compare commits

..

7 Commits

Author SHA1 Message Date
Gregor Vostrak
e8dba13eba add api key e2e tests and improve labels 2025-02-13 13:57:47 +01:00
Gregor Vostrak
91d6ff7392 add api token expiry information notices 2025-02-13 13:09:55 +01:00
Gregor Vostrak
427c904747 fix inconsistencies in dropdown highlighted item, indirectly fix flaky project member test 2025-02-13 12:51:28 +01:00
Constantin Graf
861b6c2642 Add filament resource for tokens; Ignore non-personal tokens in API token endpoints 2025-02-12 18:26:27 -05:00
Constantin Graf
51f7ba0509 Fixed api token endpoint documentation 2025-02-11 14:44:59 -05:00
Gregor Vostrak
e0506fa3e3 add frontend support for api token create, delete and revoke 2025-02-11 17:57:00 +01:00
Constantin Graf
a9d9c13846 Added API endpoints for user API tokens 2025-02-10 21:26:42 -05:00
582 changed files with 5931 additions and 19317 deletions

View File

@@ -1,47 +0,0 @@
name: Bug Report
description: "Report a bug"
body:
- type: markdown
attributes:
value: |
Before creating a new bug report, please check that there isn't already a similar issue.
- type: textarea
attributes:
label: Description
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: "Steps To Reproduce"
description: How do you trigger this bug? Please walk us through it step by step.
value: |
1.
2.
3.
...
validations:
required: false
- type: dropdown
attributes:
label: "Self-hosted or Cloud?"
options:
- Self-Hosted
- solidtime Cloud
- Both
- type: input
attributes:
label: "Version of solidtime: (for self-hosted)"
validations:
required: false
- type: input
attributes:
label: "solidtime self-hosting guide: (for self-hosted)"
description: "Did you use the official guide to self-host solidtime? If yes, which one?"
validations:
required: false

View File

@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: 🚀 Feature Request
url: https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests
about: Share ideas for new features
- name: ❓ Ask a Question
url: https://github.com/solidtime-io/solidtime/discussions/new?category=general
about: Ask the community for help

View File

@@ -1,8 +0,0 @@
<!--
This project is early stage. The structure and APIs are still subject to change and not stable.
Therefore, we do not currently accept any contributions, unless you are a member of the team.
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
-->

View File

@@ -10,8 +10,6 @@ on:
- '.github/workflows/build-private.yml' - '.github/workflows/build-private.yml'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
name: Build - Private name: Build - Private
jobs: jobs:
@@ -19,7 +17,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -110,24 +107,6 @@ jobs:
- name: "Install npm dependencies in services extension" - name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci 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" - name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
@@ -148,9 +127,6 @@ jobs:
- name: "Activate services extension" - name: "Activate services extension"
run: php artisan module:enable Services run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci

View File

@@ -11,27 +11,15 @@ on:
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env:
DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime
name: Build - Public name: Build - Public
jobs: jobs:
build: build:
strategy: runs-on: ubuntu-22.04
matrix: permissions:
include: packages: write
- runs-on: "ubuntu-24.04-arm" contents: read
platform: "linux/arm64" attestations: write
- runs-on: "ubuntu-24.04" id-token: write
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
@@ -41,7 +29,7 @@ jobs:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build" - name: "Get build"
id: release-build id: build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT" run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
@@ -52,7 +40,7 @@ jobs:
prefix: "v" prefix: "v"
- name: "Get version" - name: "Get version"
id: release-version id: version
run: | run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
@@ -73,23 +61,21 @@ jobs:
rm .env.production .env.ci .env.example rm .env.production .env.ci .env.example
- name: "Add version to .env" - name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
- name: "Add build to .env" - name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
- name: "Output .env" - name: "Output .env"
run: cat .env run: cat .env
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies" - name: "Install dependencies"
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative uses: php-actions/composer@v6
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
with:
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -102,31 +88,29 @@ jobs:
- name: "Build" - name: "Build"
run: npm run build run: npm run build
- name: "Prepare" - name: "Login to GitHub Container Registry"
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
solidtime/solidtime
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -134,85 +118,16 @@ jobs:
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: "Build and push by digest" - name: "Build and push"
id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
build-args: | build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/ DOCKER_FILES_BASE_PATH=docker/prod/
platforms: ${{ matrix.platform }} platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: "Export digest"
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest"
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
timeout-minutes: 90
needs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to Docker Hub"
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Create manifest list and push"
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)
- name: "Inspect image"
run: |
docker buildx imagetools inspect ${{ env.DOCKERHUB_REPO }}:${{ steps.meta.outputs.version }}
docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Correction;
use App\Enums\Role;
use App\Models\Member;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class CorrectionPlaceholderMembersCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'correction:placeholder-members '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sets all members who belong to a placeholder user to role placeholder';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Sets all members who belong to a placeholder user to role placeholder...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$members = Member::query()
->where('role', '!=', Role::Placeholder->value)
->whereHas('user', function (Builder $builder): void {
/** @var Builder<User> $builder */
$builder->where('is_placeholder', '=', true);
})
->get();
foreach ($members as $member) {
/** @var Member $member */
$member->role = Role::Placeholder->value;
if (! $dryRun) {
$member->save();
}
$this->line('Set role of member (id='.$member->getKey().') to placeholder');
}
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

@@ -25,10 +25,6 @@ class Kernel extends ConsoleKernel
$schedule->command('self-host:telemetry') $schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry')) ->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily(); ->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,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 ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
}

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

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField; use Novadaemon\FilamentPrettyJson\PrettyJson;
/** /**
* @source https://gitlab.com/amvisor/filament-failed-jobs * @source https://gitlab.com/amvisor/filament-failed-jobs
@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
// make text a little bit smaller because often a complete Stack Trace is shown: // 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%;']), 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); ])->columns(4);
} }

View File

@@ -4,11 +4,6 @@ declare(strict_types=1);
namespace App\Filament\Resources; 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\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
@@ -61,21 +56,6 @@ class OrganizationResource extends Resource
->searchable(['name', 'email']) ->searchable(['name', 'email'])
->disabledOn(['edit']) ->disabledOn(['edit'])
->required(), ->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') Forms\Components\Select::make('currency')
->label('Currency') ->label('Currency')
->options(function (): array { ->options(function (): array {

View File

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

View File

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

View File

@@ -23,7 +23,6 @@ use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -208,14 +207,6 @@ class UserResource extends Resource
}), }),
]) ])
->bulkActions([ ->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'], $data['timezone'],
Weekday::from($data['week_start']), Weekday::from($data['week_start']),
$data['currency'], $data['currency'],
verifyEmail: (bool) $data['is_email_verified'] (bool) $data['is_email_verified']
); );
return $user; return $user;

View File

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

View File

@@ -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; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest; use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
@@ -51,7 +50,6 @@ class InvitationController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException * @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws InvitationForTheEmailAlreadyExistsApiException
* *
* @operationId invite * @operationId invite
*/ */

View File

@@ -7,19 +7,12 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role; use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder; use App\Events\MemberMadeToPlaceholder;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberDestroyRequest;
use App\Http\Requests\V1\Member\MemberIndexRequest; use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest; use App\Http\Requests\V1\Member\MemberUpdateRequest;
use App\Http\Resources\V1\Member\MemberCollection; use App\Http\Resources\V1\Member\MemberCollection;
use App\Http\Resources\V1\Member\MemberResource; use App\Http\Resources\V1\Member\MemberResource;
@@ -31,8 +24,6 @@ use App\Service\MemberService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class MemberController extends Controller class MemberController extends Controller
{ {
@@ -72,7 +63,6 @@ class MemberController extends Controller
* @throws OrganizationNeedsAtLeastOneOwner * @throws OrganizationNeedsAtLeastOneOwner
* @throws OnlyOwnerCanChangeOwnership * @throws OnlyOwnerCanChangeOwnership
* @throws ChangingRoleToPlaceholderIsNotAllowed * @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws ChangingRoleOfPlaceholderIsNotAllowed
* *
* @operationId updateMember * @operationId updateMember
*/ */
@@ -102,13 +92,11 @@ class MemberController extends Controller
* *
* @operationId removeMember * @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); $this->checkPermission($organization, 'members:delete', $member);
$deleteRelated = $request->getDeleteRelated(); $memberService->removeMember($member, $organization);
$memberService->removeMember($member, $organization, $deleteRelated);
return response() return response()
->json(null, 204); ->json(null, 204);
@@ -117,9 +105,7 @@ class MemberController extends Controller
/** /**
* Make a member a placeholder member * Make a member a placeholder member
* *
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed * @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
*
* @operationId makePlaceholder
*/ */
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{ {
@@ -128,9 +114,6 @@ class MemberController extends Controller
if ($member->role === Role::Owner->value) { if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization; throw new CanNotRemoveOwnerFromOrganization;
} }
if ($member->role === Role::Placeholder->value) {
throw new ChangingRoleOfPlaceholderIsNotAllowed;
}
$memberService->makeMemberToPlaceholder($member); $memberService->makeMemberToPlaceholder($member);
@@ -139,42 +122,10 @@ class MemberController extends Controller
return response()->json(null, 204); return response()->json(null, 204);
} }
/**
* Merge one member into another
*
* @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws \Throwable
*
* @operationId mergeMember
*/
public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request, MemberService $memberService): JsonResponse
{
$this->checkPermission($organization, 'members:merge-into', $member);
$user = $member->user;
if ($member->role !== Role::Placeholder->value || ! $user->is_placeholder) {
throw new OnlyPlaceholdersCanBeMergedIntoAnotherMember;
}
$memberTo = Member::findOrFail($request->getMemberId());
DB::transaction(function () use ($organization, $member, $user, $memberTo, $memberService): void {
$memberService->assignOrganizationEntitiesToDifferentMember($organization, $member, $memberTo);
$member->delete();
$user->delete();
});
return response()->json(null, 204);
}
/** /**
* Invite a placeholder member to become a real member of the organization * Invite a placeholder member to become a real member of the organization
* *
* @throws AuthorizationException * @throws AuthorizationException|UserNotPlaceholderApiException
* @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
* @throws InvitationForTheEmailAlreadyExistsApiException
* *
* @operationId invitePlaceholder * @operationId invitePlaceholder
*/ */
@@ -187,10 +138,6 @@ class MemberController extends Controller
throw new UserNotPlaceholderApiException; throw new UserNotPlaceholderApiException;
} }
if (Str::endsWith($user->email, '@solidtime-import.test')) {
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
}
$invitationService->inviteUser($organization, $user->email, Role::Employee); $invitationService->inviteUser($organization, $user->email, Role::Employee);
return response()->json(null, 204); return response()->json(null, 204);

View File

@@ -40,35 +40,15 @@ class OrganizationController extends Controller
{ {
$this->checkPermission($organization, 'organizations:update'); $this->checkPermission($organization, 'organizations:update');
if ($request->getName() !== null) { $organization->name = $request->input('name');
$organization->name = $request->getName(); $oldBillableRate = $organization->billable_rate;
} if ($request->has('employees_can_see_billable_rates')) {
if ($request->getEmployeesCanSeeBillableRates() !== null) { $organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates');
$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->billable_rate = $request->getBillableRate();
$organization->save(); $organization->save();
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) { if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization); $billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
} }

View File

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

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
@@ -27,7 +26,6 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\LocalizationService;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport; use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport; use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport; use App\Service\ReportExport\TimeEntriesReportExport;
@@ -182,7 +180,6 @@ class TimeEntryController extends Controller
} }
$user = $this->user(); $user = $this->user();
$timezone = $user->timezone; $timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([ $timeEntriesQuery->with([
@@ -195,7 +192,6 @@ class TimeEntryController extends Controller
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports'; $folderPath = 'exports';
$path = $folderPath.'/'.$filename; $path = $folderPath.'/'.$filename;
$localizationService = LocalizationService::forOrganization($organization);
if ($format === ExportFormat::CSV) { if ($format === ExportFormat::CSV) {
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone); $export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone);
$export->export(); $export->export();
@@ -215,8 +211,7 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
false, false,
null, null,
null, null
$showBillableRate
); );
$html = Blade::render($viewFile, [ $html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(), 'timeEntries' => $timeEntriesQuery->get(),
@@ -225,8 +220,6 @@ class TimeEntryController extends Controller
'currency' => $organization->currency, 'currency' => $organization->currency,
'start' => $request->getStart()->timezone($timezone), 'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->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')); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) { if ($footerViewFile === false) {
@@ -261,7 +254,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else { } else {
Excel::store( Excel::store(
new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone, $localizationService), new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone),
$path, $path,
config('filesystems.private'), config('filesystems.private'),
$format->getExportPackageType(), $format->getExportPackageType(),
@@ -292,18 +285,18 @@ class TimeEntryController extends Controller
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int|null * cost: int
* } * }
* } * }
* *
@@ -319,7 +312,6 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$user = $this->user(); $user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
@@ -333,8 +325,7 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
$request->getFillGapsInTimeGroups(), $request->getFillGapsInTimeGroups(),
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd()
$showBillableRate
); );
return [ return [
@@ -368,7 +359,6 @@ class TimeEntryController extends Controller
} }
$debug = $request->getDebug(); $debug = $request->getDebug();
$user = $this->user(); $user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group = $request->getGroup(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();
@@ -382,8 +372,7 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
false, false,
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd()
$showBillableRate
); );
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries( $dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(), $timeEntriesAggregateQuery->clone(),
@@ -393,12 +382,10 @@ class TimeEntryController extends Controller
$user->week_start, $user->week_start,
true, true,
$request->getStart(), $request->getStart(),
$request->getEnd(), $request->getEnd()
$showBillableRate
); );
$currency = $organization->currency; $currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $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(); $filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports'; $folderPath = 'exports';
@@ -424,12 +411,9 @@ class TimeEntryController extends Controller
'currency' => $currency, 'currency' => $currency,
'group' => $group, 'group' => $group,
'subGroup' => $subGroup, 'subGroup' => $subGroup,
'timezone' => $timezone,
'start' => $request->getStart()->timezone($timezone), 'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone),
'debug' => $debug, 'debug' => $debug,
'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]); ]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php')); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) { if ($footerViewFile === false) {
@@ -458,7 +442,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else { } else {
Excel::store( Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate), new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
$path, $path,
config('filesystems.private'), config('filesystems.private'),
$format->getExportPackageType(), $format->getExportPackageType(),

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Service\DashboardService; use App\Service\DashboardService;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -20,14 +19,30 @@ class DashboardController extends Controller
{ {
$user = $this->user(); $user = $this->user();
$organization = $this->currentOrganization(); $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; $latestTeamActivity = null;
if ($permissionStore->has($organization, 'time-entries:view:all')) { if ($permissionStore->has($organization, 'time-entries:view:all')) {
$latestTeamActivity = $dashboardService->latestTeamActivity($organization); $latestTeamActivity = $dashboardService->latestTeamActivity($organization);
} }
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; return Inertia::render('Dashboard', [
'weeklyProjectOverview' => $weeklyProjectOverview,
return Inertia::render('Dashboard'); '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; namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
@@ -19,7 +20,8 @@ class EnsureEmailIsVerified
{ {
if (! app()->isLocal()) { if (! app()->isLocal()) {
if ($request->user() === null || if ($request->user() === null ||
(! $request->user()->hasVerifiedEmail())) { ($request->user() instanceof MustVerifyEmail &&
! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson() return $request->expectsJson()
? abort(403, 'Your email address is not verified.') ? abort(403, 'Your email address is not verified.')
: Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice')); : Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));

View File

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

View File

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

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ApiToken; 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. * 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; namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule; 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. * 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; namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class ClientStoreRequest extends BaseFormRequest class ClientStoreRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
* @property Client|null $client Client 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. * 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; namespace App\Http\Requests\V1\Import;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule; 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. * 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; namespace App\Http\Requests\V1\Invitation;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class InvitationIndexRequest extends BaseFormRequest class InvitationIndexRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role; use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class InvitationStoreRequest extends BaseFormRequest class InvitationStoreRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
@@ -26,6 +29,10 @@ class InvitationStoreRequest extends BaseFormRequest
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.invitation_already_exists'),
], ],
'role' => [ 'role' => [
'required', 'required',

View File

@@ -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; namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class MemberIndexRequest extends BaseFormRequest class MemberIndexRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.

View File

@@ -1,42 +0,0 @@
<?php
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 Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class MemberMergeIntoRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
// ID of the member to which the data should be transferred (destination)
'member_id' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
];
}
public function getMemberId(): string
{
return (string) $this->input('member_id');
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,17 +7,17 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule; use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class ReportStoreRequest extends BaseFormRequest class ReportStoreRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
@@ -40,7 +40,7 @@ class ReportStoreRequest extends BaseFormRequest
'required', 'required',
'boolean', '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' => [ 'public_until' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report; namespace App\Http\Requests\V1\Report;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class ReportUpdateRequest extends BaseFormRequest class ReportUpdateRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Tag; use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TagStoreRequest extends BaseFormRequest class TagStoreRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Tag; use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
* @property Tag|null $tag Tag 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. * 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; namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TaskIndexRequest extends BaseFormRequest class TaskIndexRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TaskStoreRequest extends BaseFormRequest class TaskStoreRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
* @property Task|null $task Task 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. * Get the validation rules that apply to the request.

View File

@@ -7,7 +7,6 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
@@ -17,6 +16,7 @@ use App\Models\Task;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -24,7 +24,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class TimeEntryAggregateExportRequest extends BaseFormRequest class TimeEntryAggregateExportRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
@@ -15,6 +14,7 @@ use App\Models\Task;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -22,7 +22,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class TimeEntryAggregateRequest extends BaseFormRequest class TimeEntryAggregateRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TimeEntryDestroyMultipleRequest extends BaseFormRequest class TimeEntryDestroyMultipleRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
@@ -13,12 +12,13 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class TimeEntryIndexRequest extends BaseFormRequest class TimeEntryIndexRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -12,12 +11,13 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TimeEntryStoreRequest extends BaseFormRequest class TimeEntryStoreRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
@@ -59,12 +59,12 @@ class TimeEntryStoreRequest extends BaseFormRequest
->where('project_id', $this->input('project_id')); ->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')), })->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' => [ 'start' => [
'required', 'required',
'date_format:Y-m-d\TH:i:s\Z', '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' => [ 'end' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', '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; namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -12,12 +11,13 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TimeEntryUpdateMultipleRequest extends BaseFormRequest class TimeEntryUpdateMultipleRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -12,12 +11,13 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TimeEntryUpdateRequest extends BaseFormRequest class TimeEntryUpdateRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
@@ -59,11 +59,11 @@ class TimeEntryUpdateRequest extends BaseFormRequest
->where('project_id', $this->input('project_id')); ->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')), })->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' => [ 'start' => [
'date_format:Y-m-d\TH:i:s\Z', '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' => [ 'end' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', '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 protected function formatDateTime(?Carbon $carbon): ?string
{ {
return $carbon?->toIso8601ZuluString(); 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; 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\Http\Resources\V1\BaseResource;
use App\Models\Organization; use App\Models\Organization;
use App\Service\CurrencyService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
/** /**
@@ -40,8 +34,6 @@ class OrganizationResource extends BaseResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$currencyService = app(CurrencyService::class);
return [ return [
/** @var string $id ID */ /** @var string $id ID */
'id' => $this->resource->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, 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var string $currency Currency code (ISO 4217) */ /** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency, '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 App\Models\Project;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
private bool $showBillableRates; private bool $showBillableRates;
/**
* @param LengthAwarePaginator<Project> $resource
*/
public function __construct($resource, bool $showBillableRates) public function __construct($resource, bool $showBillableRates)
{ {
parent::__construct($resource); parent::__construct($resource);

View File

@@ -30,7 +30,7 @@ class DetailedReportResource extends BaseResource
/** @var bool $is_public Whether the report can be accessed via an external link */ /** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report 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 */ /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(), 'shareable_link' => $this->resource->getShareableLink(),
'properties' => [ 'properties' => [
@@ -41,9 +41,9 @@ class DetailedReportResource extends BaseResource
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */ /** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value, 'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */ /** @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 */ /** @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 */ /** @var bool|null $active Whether the report is active */
'active' => $this->resource->properties->active, 'active' => $this->resource->properties->active,
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */ /** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
@@ -60,9 +60,9 @@ class DetailedReportResource extends BaseResource
'task_ids' => $this->resource->properties->taskIds?->toArray(), 'task_ids' => $this->resource->properties->taskIds?->toArray(),
], ],
/** @var string $created_at Date when the report was created */ /** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
/** @var string $updated_at Date when the report was last updated */ /** @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; 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\Http\Resources\V1\BaseResource;
use App\Models\Report; use App\Models\Report;
use App\Service\CurrencyService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
/** /**
@@ -24,20 +18,20 @@ use Illuminate\Http\Request;
* description: string|null, * description: string|null,
* color: string|null, * color: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: string|null, * grouped_type: string|null,
* grouped_data: null|array<array{ * grouped_data: null|array<array{
* key: string|null, * key: string|null,
* description: string|null, * description: string|null,
* color: string|null, * color: string|null,
* seconds: int, * seconds: int,
* cost: int|null, * cost: int,
* grouped_type: null, * grouped_type: null,
* grouped_data: null * grouped_data: null
* }> * }>
* }>, * }>,
* seconds: int, * seconds: int,
* cost: int|null * cost: int
* } * }
*/ */
class DetailedWithDataReportResource extends BaseResource class DetailedWithDataReportResource extends BaseResource
@@ -70,29 +64,15 @@ class DetailedWithDataReportResource extends BaseResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$currencyService = app(CurrencyService::class);
return [ return [
/** @var string $name Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** @var string|null $email Description */ /** @var string|null $email Description */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** @var string|null $public_until Date until the report 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 $currency Currency code (ISO 4217) */ /** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency, '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' => [ 'properties' => [
/** @var string $group Type of first grouping */ /** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value, '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) */ /** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value, 'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */ /** @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 */ /** @var string $end End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end), 'end' => $this->resource->properties->end->toIso8601ZuluString(),
], ],
/** @var array{ /** @var array{
* grouped_type: string|null, * 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 */ /** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report 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 */ /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(), 'shareable_link' => $this->resource->getShareableLink(),
/** @var string $created_at Date when the report was created */ /** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
/** @var string $updated_at Date when the report was last updated */ /** @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

@@ -6,7 +6,7 @@ namespace App\Listeners;
use App\Models\Member; use App\Models\Member;
use App\Models\User; use App\Models\User;
use App\Service\MemberService; use App\Service\UserService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Laravel\Jetstream\Events\TeamMemberAdded; use Laravel\Jetstream\Events\TeamMemberAdded;
@@ -17,11 +17,8 @@ class RemovePlaceholder
*/ */
public function handle(TeamMemberAdded $event): void public function handle(TeamMemberAdded $event): void
{ {
$memberService = app(MemberService::class); /** @var UserService $userService */
$member = Member::query() $userService = app(UserService::class);
->whereBelongsTo($event->team, 'organization')
->whereBelongsTo($event->user, 'user')
->firstOrFail();
$placeholders = Member::query() $placeholders = Member::query()
->whereHas('user', function (Builder $query) use ($event): void { ->whereHas('user', function (Builder $query) use ($event): void {
/** @var Builder<User> $query */ /** @var Builder<User> $query */
@@ -35,7 +32,7 @@ class RemovePlaceholder
foreach ($placeholders as $placeholder) { foreach ($placeholders as $placeholder) {
/** @var Member $placeholder */ /** @var Member $placeholder */
$placeholderUser = $placeholder->user; $placeholderUser = $placeholder->user;
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member); $userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user);
$placeholder->delete(); $placeholder->delete();
$placeholderUser->delete(); $placeholderUser->delete();
} }

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\MemberFactory; use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -25,8 +24,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Organization $organization * @property-read Organization $organization
* @property-read User $user * @property-read User $user
* @property-read Collection<int, ProjectMember> $projectMembers
* @property-read Collection<int, TimeEntry> $timeEntries
* *
* @method static MemberFactory factory() * @method static MemberFactory factory()
*/ */
@@ -47,7 +44,7 @@ class Member extends JetstreamMembership implements AuditableContract
protected $table = 'members'; protected $table = 'members';
/** /**
* @return BelongsTo<User, $this> * @return BelongsTo<User, Member>
*/ */
public function user(): BelongsTo public function user(): BelongsTo
{ {
@@ -55,7 +52,7 @@ class Member extends JetstreamMembership implements AuditableContract
} }
/** /**
* @return BelongsTo<Organization, $this> * @return BelongsTo<Organization, Member>
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
@@ -63,15 +60,7 @@ class Member extends JetstreamMembership implements AuditableContract
} }
/** /**
* @return HasMany<TimeEntry, $this> * @return HasMany<ProjectMember>
*/
public function timeEntries(): HasMany
{
return $this->hasMany(TimeEntry::class, 'member_id');
}
/**
* @return HasMany<ProjectMember, $this>
*/ */
public function projectMembers(): HasMany public function projectMembers(): HasMany
{ {

View File

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

View File

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

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