Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
47327aa52b Updated compose dependencies 2026-09-21 16:08:58 +02:00
294 changed files with 1676 additions and 13232 deletions

View File

@@ -6,7 +6,6 @@ APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true APP_ENABLE_REGISTRATION=true
SUPER_ADMINS=admin@example.com
# Logging # Logging
LOG_CHANNEL=stack LOG_CHANNEL=stack

View File

@@ -103,8 +103,6 @@ jobs:
{ {
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)" echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)" echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension" - name: "Checkout invoicing extension"
@@ -121,26 +119,9 @@ jobs:
- name: "Install npm dependencies in invoicing extension" - name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
run: cd extensions/Auditing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Activate invoicing extension" - name: "Activate invoicing extension"
run: php artisan module:enable Invoicing run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci
@@ -160,7 +141,7 @@ jobs:
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -214,7 +195,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}

View File

@@ -84,8 +84,6 @@ jobs:
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)" echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)" echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)" echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension" - name: "Checkout billing extension"
@@ -99,7 +97,7 @@ jobs:
- name: "Install dependencies in billing extension" - name: "Install dependencies in billing extension"
uses: php-actions/composer@v6 uses: php-actions/composer@v6
env: env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}' COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
with: with:
working_dir: "extensions/Billing" working_dir: "extensions/Billing"
command: install command: install
@@ -147,25 +145,6 @@ jobs:
- name: "Install npm dependencies in invoicing extension" - name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
uses: php-actions/composer@v6
with:
working_dir: "extensions/Auditing"
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 auditing extension"
run: cd extensions/Auditing && 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:
@@ -189,9 +168,6 @@ jobs:
- name: "Activate invoicing extension" - name: "Activate invoicing extension"
run: php artisan module:enable Invoicing run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci
@@ -201,7 +177,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin

View File

@@ -117,13 +117,13 @@ jobs:
${{ env.GHCR_REPO }} ${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry" - name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
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@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -177,13 +177,13 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to Docker Hub" - name: "Login to Docker Hub"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR" - name: "Login to GHCR"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -1,31 +0,0 @@
name: Extension Manifest Check
on:
pull_request:
paths:
- extensions/manifest.json
- .github/workflows/extension-manifest-check.yml
permissions:
contents: read
jobs:
check-refs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
# Extension refs must be pinned to a release tag (vX.Y.Z) or a full commit
# SHA. Branch names (main, feature/foo, ...) are only acceptable while
# developing and must be replaced before the pull request is merged.
- name: "Check extension refs are pinned"
run: |
failed=0
while IFS=$'\t' read -r name ref; do
if [[ "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.]+)?$ ]] || [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
echo "✅ $name: $ref"
else
echo "::error file=extensions/manifest.json::Extension \"$name\" uses ref \"$ref\", which is not a release tag or commit SHA. Pin it before merging."
failed=1
fi
done < <(jq -r 'to_entries[] | [.key, .value.ref] | @tsv' extensions/manifest.json)
exit $failed

View File

@@ -1,8 +1,6 @@
name: NPM Format Check name: NPM Format Check
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
format-check: format-check:

View File

@@ -1,136 +0,0 @@
name: PHPUnit Tests - Extensions
on: push
permissions:
contents: read
jobs:
phpunit-extensions:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:
image: postgres:${{ matrix.postgres_version }}
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
POSTGRES_USER: 'root'
POSTGRES_PASSWORD: 'root'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: pcov
- uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
- name: "Install composer dependencies in billing extension"
working-directory: extensions/Billing
env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in billing extension"
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
- name: "Install composer dependencies in services extension"
working-directory: extensions/Services
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
working-directory: extensions/Invoicing
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Run composer install"
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Activate billing extension"
run: php artisan module:enable Billing
- name: "Activate services extension"
run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install dependencies"
run: npm ci
- name: "Build Frontend"
run: npm run build
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
- name: "Run PHPUnit"
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure

View File

@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v7.1.1 uses: codecov/codecov-action@v7.0.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -1,185 +0,0 @@
name: Playwright Tests - Extensions
on: push
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.variant }})
runs-on: ubuntu-latest
timeout-minutes: 60
# Same extension sets as build-private.yml and build-onpremise.yml, so the extension specs run
# with and without Billing.
strategy:
fail-fast: false
matrix:
include:
- variant: private
billing: true
extensions: Billing Services Invoicing
- variant: onpremise
billing: false
extensions: Invoicing
services:
mailpit:
image: 'axllent/mailpit:latest'
ports:
- 1025:1025
- 8025:8025
pgsql_test:
image: postgres:15
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
POSTGRES_USER: 'root'
POSTGRES_PASSWORD: 'root'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Setup node"
uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: none
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
if: matrix.billing
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
- name: "Install composer dependencies in billing extension"
if: matrix.billing
working-directory: extensions/Billing
env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in billing extension"
if: matrix.billing
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
if: matrix.billing
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
- name: "Install composer dependencies in services extension"
if: matrix.billing
working-directory: extensions/Services
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in services extension"
if: matrix.billing
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
working-directory: extensions/Invoicing
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Run composer install"
run: composer install -n --prefer-dist
# Must run before `migrate` so the extensions' migrations are applied, and before the
# frontend build so vite collects the extensions' assets. It also writes
# modules_statuses.json, which playwright.config.ts reads to collect the extension specs.
- name: "Activate extensions"
run: |
for extension in ${{ matrix.extensions }}; do
php artisan module:enable "$extension"
done
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
php artisan migrate --seed
- name: "Install dependencies"
run: npm ci
- name: "Build Frontend"
run: npm run build
- name: "Install FrankenPHP"
run: |
ARCH="$(uname -m)"
curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp
chmod +x /usr/local/bin/frankenphp
- name: "Run Laravel Octane Server"
run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &
env:
OCTANE_SERVER: frankenphp
- name: "Install Playwright Browsers"
run: npx playwright install --with-deps
# Only the extension specs, the core specs run without extensions in playwright.yml
- name: "Run Playwright tests"
run: npx playwright test extensions/
env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
MAILPIT_BASE_URL: 'http://localhost:8025'
# No artifacts are uploaded on purpose. This repository is public, so every artifact is
# downloadable by anyone, and Playwright's reports carry the source of the specs that
# produced them (traces embed whole source files, error-context.md embeds a window of the
# spec). Those specs live in the private extension repositories. Failures are diagnosed from
# the job log; to inspect a trace, reproduce the failure locally with the extension checked out.

2
.gitignore vendored
View File

@@ -6,7 +6,6 @@ dist
/public/storage /public/storage
/public/css /public/css
/public/js /public/js
/public/fonts/filament
/public/vendor /public/vendor
/lang/vendor /lang/vendor
/storage/*.key /storage/*.key
@@ -44,5 +43,4 @@ yarn-error.log
/data /data
/config/caddy /config/caddy
/config/composer /config/composer
/config/psysh
/AGENTS.md /AGENTS.md

View File

@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Correction;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Illuminate\Console\Command;
class CorrectionDeleteTimeEntriesWithEndBeforeStartCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'correction:delete-time-entries-with-end-before-start '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Deletes all time entries where the end is before the start';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Deletes all time entries where the end is before the start...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$timeEntries = TimeEntry::query()
->whereNotNull('end')
->whereColumn('end', '<', 'start')
->with(['project', 'task'])
->get();
foreach ($timeEntries as $timeEntry) {
/** @var TimeEntry $timeEntry */
if (! $dryRun) {
// Delete via Eloquent, so that the deletion is audited
$timeEntry->delete();
if ($timeEntry->project !== null) {
RecalculateSpentTimeForProject::dispatch($timeEntry->project);
}
if ($timeEntry->task !== null) {
RecalculateSpentTimeForTask::dispatch($timeEntry->task);
}
}
$this->line('Deleted time entry (id='.$timeEntry->getKey().', start='.$timeEntry->start->toIso8601ZuluString().', end='.$timeEntry->end?->toIso8601ZuluString().')');
}
return self::SUCCESS;
}
}

View File

@@ -59,14 +59,6 @@ class SelfHostDatabaseConsistency extends Command
->get(); ->get();
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem); $this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
// End of time entries can not be before the start
$problems = DB::table('time_entries')
->select(['id'])
->whereNotNull('end')
->whereColumn('end', '<', 'start')
->get();
$this->logProblems($problems, 'Time entries have an end that is before the start', $hadAProblem);
// Every user needs to be a member of at least one organization // Every user needs to be a member of at least one organization
$problems = DB::table('users') $problems = DB::table('users')
->select(['users.id as id']) ->select(['users.id as id'])

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum GoalComparison: string
{
use LaravelEnumHelper;
/**
* The goal is reached when at least the target amount of time was tracked in the period.
*/
case AtLeast = 'at_least';
/**
* The goal is reached when less than the target amount of time was tracked in the period.
*/
case LessThan = 'less_than';
}

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum GoalPeriod: string
{
use LaravelEnumHelper;
case Day = 'day';
case Week = 'week';
case Month = 'month';
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum GoalStatus: string
{
use LaravelEnumHelper;
/**
* "At least" goal, the target has not been reached yet in the current period.
*/
case InProgress = 'in_progress';
/**
* "At least" goal, the target has been reached in the current period.
*/
case Achieved = 'achieved';
/**
* "Less than" goal, the tracked time is still below the limit.
*/
case OnTrack = 'on_track';
/**
* "Less than" goal, the tracked time reached or exceeded the limit.
*/
case Exceeded = 'exceeded';
}

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum GoalType: string
{
use LaravelEnumHelper;
/**
* A goal a member set for themselves. Only that member can see and manage it, no permission reaches it.
*/
case Personal = 'personal';
/**
* A goal the organization set for a member or for the whole organization.
* Managed by the members that may manage goals, visible to them and to the member the goal is for.
* Requires the team goals extension.
*/
case Organization = 'organization';
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Extensions\Scramble;
use App\Http\Resources\PaginatedResourceCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Schema;
use Dedoc\Scramble\Support\Generator\Types\ArrayType;
use Dedoc\Scramble\Support\Generator\Types\BooleanType;
use Dedoc\Scramble\Support\Generator\Types\IntegerType;
use Dedoc\Scramble\Support\Generator\Types\ObjectType as OpenApiObjectType;
use Dedoc\Scramble\Support\Generator\Types\StringType;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\Type;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Resources\Json\JsonResource;
class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
{
public function shouldHandle(Type $type): bool
{
return $type instanceof ObjectType
&& $type->isInstanceOf(PaginatedResourceCollection::class);
}
public function toSchema(Type $type): ?OpenApiObjectType
{
/** @var Type|null $collectingClassType */
$collectingClassType = $type->templateTypes[0] ?? null;
if (! $collectingClassType instanceof ObjectType) {
return null;
}
if (! $collectingClassType->isInstanceOf(JsonResource::class) && ! $collectingClassType->isInstanceOf(Model::class)) {
return null;
}
$collectingType = $this->openApiTransformer->transform($collectingClassType);
$newType = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
$newType->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['total'])
);
$newType->setRequired(['data', 'meta']);
} else {
$newType->addProperty(
'links',
(new OpenApiObjectType)
->addProperty('first', (new StringType)->nullable(true))
->addProperty('last', (new StringType)->nullable(true))
->addProperty('prev', (new StringType)->nullable(true))
->addProperty('next', (new StringType)->nullable(true))
->setRequired(['first', 'last', 'prev', 'next'])
);
$newType->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('current_page', new IntegerType)
->addProperty('from', (new IntegerType)->nullable(true))
->addProperty('last_page', new IntegerType)
->addProperty('links', (new ArrayType)->setItems(
(new OpenApiObjectType)
->addProperty('url', (new StringType)->nullable(true))
->addProperty('label', new StringType)
->addProperty('active', new BooleanType)
->setRequired(['url', 'label', 'active'])
)->setDescription('Generated paginator links.'))
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
);
$newType->setRequired(['data', 'links', 'meta']);
}
return $newType;
}
/**
* @param Generic $type
*/
public function toResponse(Type $type): ?Response
{
/** @var ObjectType|null $collectingClassType */
$collectingClassType = $type->templateTypes[0] ?? null;
if (! $collectingClassType instanceof ObjectType) {
return null;
}
$type = $this->toSchema($type);
return Response::make(200)
->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`')
->setContent('application/json', Schema::fromType($type));
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages\CreateAudit;
use App\Filament\Resources\AuditResource\Pages\ListAudits;
use App\Filament\Resources\AuditResource\Pages\ViewAudit;
use App\Models\Audit;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static string|\UnitEnum|null $navigationGroup = 'System';
public static function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('user_type')
->maxLength(255),
TextInput::make('user_id'),
TextInput::make('event')
->required()
->maxLength(255),
TextInput::make('auditable_type')
->required()
->maxLength(255),
TextInput::make('auditable_id')
->required(),
PrettyJsonField::make('old_values'),
PrettyJsonField::make('new_values'),
Textarea::make('url'),
TextInput::make('ip_address'),
TextInput::make('user_agent')
->maxLength(1023),
TextInput::make('tags')
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name'),
TextColumn::make('event'),
TextColumn::make('auditable_type'),
TextColumn::make('auditable_id'),
IconColumn::make('was_command')
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
->boolean(),
TextColumn::make('created_at')
->sortable()
->dateTime(),
TextColumn::make('updated_at')
->sortable()
->dateTime(),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
])
->toolbarActions([
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => ListAudits::route('/'),
'create' => CreateAudit::route('/create'),
'view' => ViewAudit::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAudit extends CreateRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ListRecords;
class ListAudits extends ListRecords
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -1,174 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalType;
use App\Enums\Weekday;
use App\Filament\Resources\GoalResource\Pages;
use App\Models\Goal;
use App\Service\Dto\GoalFiltersDto;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class GoalResource extends Resource
{
protected static ?string $model = Goal::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-viewfinder-circle';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 8;
public static function form(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\Select::make('type')
->label('Type')
->options(collect(GoalType::cases())->mapWithKeys(fn (GoalType $type): array => [$type->value => $type->name])->all())
->disabled()
->required(),
Forms\Components\Select::make('comparison')
->label('Comparison')
->options(collect(GoalComparison::cases())->mapWithKeys(fn (GoalComparison $comparison): array => [$comparison->value => $comparison->name])->all())
->required(),
Forms\Components\TextInput::make('target_seconds')
->label('Target (seconds)')
->numeric()
->minValue(1)
->required(),
Forms\Components\Select::make('period')
->label('Period')
->options(collect(GoalPeriod::cases())->mapWithKeys(fn (GoalPeriod $period): array => [$period->value => $period->name])->all())
->required(),
Forms\Components\Select::make('timezone')
->label('Timezone')
->options(fn (): array => collect(\DateTimeZone::listIdentifiers())->mapWithKeys(fn (string $timezone): array => [$timezone => $timezone])->all())
->searchable()
->required(),
Forms\Components\Select::make('week_start')
->label('Week start')
->options(collect(Weekday::cases())->mapWithKeys(fn (Weekday $weekday): array => [$weekday->value => $weekday->name])->all())
->required(),
DateTimePicker::make('archived_at')
->label('Archived At'),
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\Select::make('member_id')
->label('Member (empty = every member)')
->relationship(name: 'member', titleAttribute: 'id')
->disabled(),
PrettyJsonField::make('filters')
->formatStateUsing(function (GoalFiltersDto $state, Goal $record): string {
return $record->getRawOriginal('filters');
})
->disabled(),
DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('comparison')
->sortable(),
TextColumn::make('target_seconds')
->label('Target')
->formatStateUsing(fn (int $state): string => sprintf('%dh %02dm', intdiv($state, 3600), intdiv($state % 3600, 60)))
->sortable(),
TextColumn::make('period')
->sortable(),
TextColumn::make('type')
->sortable(),
TextColumn::make('member.user.email')
->label('Member')
->placeholder('Every member')
->searchable(),
TextColumn::make('archived_at')
->dateTime()
->placeholder('Not archived')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('organization.name')
->searchable()
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable(),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('type')
->label('Type')
->options(collect(GoalType::cases())->mapWithKeys(fn (GoalType $type): array => [$type->value => $type->name])->all()),
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListGoals::route('/'),
'edit' => Pages\EditGoal::route('/{record}/edit'),
'view' => Pages\ViewGoal::route('/{record}'),
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\GoalResource\Pages;
use App\Filament\Resources\GoalResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditGoal extends EditRecord
{
protected static string $resource = GoalResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\GoalResource\Pages;
use App\Filament\Resources\GoalResource;
use Filament\Resources\Pages\ListRecords;
class ListGoals extends ListRecords
{
protected static string $resource = GoalResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\GoalResource\Pages;
use App\Filament\Resources\GoalResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewGoal extends ViewRecord
{
protected static string $resource = GoalResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
];
}
}

View File

@@ -23,7 +23,7 @@ use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto; use App\Service\Import\Importers\ReportDto;
use App\Service\Import\ImportService; use App\Service\Import\ImportService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Brick\Money\IsoCurrencyProvider; use Brick\Money\ISOCurrencyProvider;
use Exception; use Exception;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
@@ -40,7 +40,6 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use League\Flysystem\UnableToReadFile;
class OrganizationResource extends Resource class OrganizationResource extends Resource
{ {
@@ -89,7 +88,7 @@ class OrganizationResource extends Resource
Select::make('currency') Select::make('currency')
->label('Currency') ->label('Currency')
->options(function (): array { ->options(function (): array {
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies(); $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = []; $select = [];
foreach ($currencies as $currency) { foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')'; $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
@@ -182,13 +181,9 @@ class OrganizationResource extends Resource
->icon('heroicon-o-inbox-arrow-down') ->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data): void { ->action(function (Organization $record, array $data): void {
try { try {
try { $file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
} catch (UnableToReadFile) {
$file = null;
}
if ($file === null) { if ($file === null) {
throw new ImportException('File not found'); throw new Exception('File not found');
} }
/** @var string $timezone */ /** @var string $timezone */
$timezone = $data['timezone']; $timezone = $data['timezone'];

View File

@@ -98,10 +98,9 @@ class UsersRelationManager extends RelationManager
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member); app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
} }
$newRole = $data['role'] instanceof Role ? $data['role'] : Role::from($data['role']); if ($data['role'] !== $member->role) {
if ($newRole->value !== $member->role) {
try { try {
app(MemberService::class)->changeRole($member, $organization, $newRole, true); app(MemberService::class)->changeRole($member, $organization, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true);
} catch (ApiException $exception) { } catch (ApiException $exception) {
Notification::make() Notification::make()
->danger() ->danger()

View File

@@ -16,7 +16,7 @@ use App\Models\User;
use App\Service\DeletionService; use App\Service\DeletionService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use App\Service\UserService; use App\Service\UserService;
use Brick\Money\IsoCurrencyProvider; use Brick\Money\ISOCurrencyProvider;
use Exception; use Exception;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
@@ -115,7 +115,7 @@ class UserResource extends Resource
Select::make('currency') Select::make('currency')
->label('Currency (Personal Organization)') ->label('Currency (Personal Organization)')
->options(function (): array { ->options(function (): array {
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies(); $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = []; $select = [];
foreach ($currencies as $currency) { foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')'; $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';

View File

@@ -59,10 +59,9 @@ class OrganizationsRelationManager extends RelationManager
/** @var Member $member */ /** @var Member $member */
$member = $record->getRelation('membership'); $member = $record->getRelation('membership');
$newRole = $data['role'] instanceof Role ? $data['role'] : Role::from($data['role']); if ($data['role'] !== $member->role) {
if ($newRole->value !== $member->role) {
try { try {
app(MemberService::class)->changeRole($member, $record, $newRole, true); app(MemberService::class)->changeRole($member, $record, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true);
} catch (ApiException $exception) { } catch (ApiException $exception) {
Notification::make() Notification::make()
->danger() ->danger()

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Dedoc\Scramble\Attributes\ExcludeAllRoutesFromDocs;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Fallback for unknown /api/* routes, to prevent a rendered HTML page
*/
#[ExcludeAllRoutesFromDocs]
class FallbackController extends Controller
{
public function __invoke(): never
{
throw new NotFoundHttpException('API resource not found');
}
}

View File

@@ -29,6 +29,8 @@ class ClientController extends Controller
/** /**
* Get clients * Get clients
* *
* @return ClientCollection<ClientResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getClients * @operationId getClients

View File

@@ -7,7 +7,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Service\CurrencyService; use App\Service\CurrencyService;
use Brick\Money\Currency; use Brick\Money\Currency;
use Brick\Money\IsoCurrencyProvider; use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
class CurrencyController extends Controller class CurrencyController extends Controller
@@ -29,7 +29,7 @@ class CurrencyController extends Controller
'name' => $currency->getName(), 'name' => $currency->getName(),
'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()), 'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()),
], ],
IsoCurrencyProvider::getInstance()->getAvailableCurrencies() ISOCurrencyProvider::getInstance()->getAvailableCurrencies()
)); ));
return response()->json($currencies); return response()->json($currencies);

View File

@@ -1,241 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\GoalType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Http\Requests\V1\Goal\GoalIndexRequest;
use App\Http\Requests\V1\Goal\GoalStoreRequest;
use App\Http\Requests\V1\Goal\GoalUpdateRequest;
use App\Http\Resources\V1\Goal\GoalCollection;
use App\Http\Resources\V1\Goal\GoalResource;
use App\Models\Goal;
use App\Models\Organization;
use App\Service\BillingContract;
use App\Service\GoalProgressService;
use App\Service\GoalsContract;
use App\Service\TimezoneService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
class GoalController extends Controller
{
/**
* @throws AuthorizationException
*/
protected function checkPermission(Organization $organization, string $permission, ?Goal $goal = null): void
{
parent::checkPermission($organization, $permission);
if ($goal !== null) {
$this->checkGoalBelongsToOrganization($organization, $goal);
}
}
/**
* @throws AuthorizationException
*/
private function checkGoalBelongsToOrganization(Organization $organization, Goal $goal): void
{
if ($goal->organization_id !== $organization->id) {
throw new AuthorizationException('Goal does not belong to organization');
}
}
/**
* Enforces the goal limit provided by the BillingContract, if any. Archived goals count toward the limit.
*
* @throws FeatureIsNotAvailableInFreePlanApiException
*/
private function checkGoalLimit(Organization $organization): void
{
$limit = app(BillingContract::class)->getGoalLimit($organization);
if ($limit === null) {
return;
}
$goals = Goal::query()
->whereBelongsTo($organization, 'organization')
->count();
if ($goals >= $limit) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
}
/**
* Get goals
*
* Returns the goals the current member is allowed to see, including the progress in the current period.
*
* @throws AuthorizationException
*
* @operationId getGoals
*/
public function index(Organization $organization, GoalIndexRequest $request, GoalsContract $access, GoalProgressService $progressService): GoalCollection
{
// An organization goal is visible to the member it is for, who does not hold the :organization-type permission.
// Either permission is enough, the access contract decides which goals come back.
$this->checkAnyPermission($organization, ['goals:view:own', 'goals:view:organization-type']);
$member = $this->member($organization);
$query = Goal::query()
->whereBelongsTo($organization, 'organization')
->with(['member.user'])
->orderBy('created_at', 'desc')
->orderBy('id');
$query = $access->scopeVisibleGoals($query, $member);
if ($request->getType() !== null) {
$query->where('type', '=', $request->getType()->value);
}
if ($request->getArchivedFilter() === 'true') {
$query->archived();
} elseif ($request->getArchivedFilter() === 'false') {
$query->notArchived();
}
$goals = $query->paginate(config('app.pagination_per_page_default'));
$progressByGoalId = $progressService->getCurrentProgressForGoals($goals->getCollection(), Carbon::now());
return new GoalCollection($goals, $progressByGoalId);
}
/**
* Get goal
*
* @throws AuthorizationException
*
* @operationId getGoal
*/
public function show(Organization $organization, Goal $goal, GoalsContract $access, GoalProgressService $progressService): GoalResource
{
// Either permission is enough, see index
$this->checkAnyPermission($organization, ['goals:view:own', 'goals:view:organization-type']);
$this->checkGoalBelongsToOrganization($organization, $goal);
$member = $this->member($organization);
if (! $access->canViewGoal($member, $goal)) {
throw new AuthorizationException;
}
$goal->load('member.user');
return new GoalResource($goal, $progressService->getCurrentProgress($goal, Carbon::now()));
}
/**
* Create goal
*
* @throws AuthorizationException|FeatureIsNotAvailableInFreePlanApiException
*
* @operationId createGoal
*/
public function store(Organization $organization, GoalStoreRequest $request, GoalsContract $access, GoalProgressService $progressService): JsonResponse
{
if ($request->getType() === GoalType::Personal) {
$this->checkPermission($organization, 'goals:create:own');
} else {
$this->checkPermission($organization, 'goals:create:organization-type');
}
$member = $this->member($organization);
$type = $request->getType();
$targetMemberId = $request->hasMemberId() ? $request->getMemberId() : $member->getKey();
if (! $access->canCreateGoal($member, $type, $targetMemberId)) {
throw new AuthorizationException;
}
$this->checkGoalLimit($organization);
$user = $member->user;
$goal = new Goal;
$goal->name = $request->getName();
$goal->type = $type;
$goal->comparison = $request->getComparison();
$goal->target_seconds = $request->getTargetSeconds();
$goal->period = $request->getPeriod();
$goal->filters = $request->getFilters();
$goal->timezone = $request->getTimezone() ?? app(TimezoneService::class)->getTimezoneFromUser($user)->getName();
$goal->week_start = $request->getWeekStart() ?? $user->week_start;
$goal->organization()->associate($organization);
$goal->member_id = $targetMemberId;
$goal->save();
$goal->load('member.user');
return (new GoalResource($goal, $progressService->getCurrentProgress($goal, Carbon::now())))
->response()
->setStatusCode(201);
}
/**
* Update goal
*
* The type of a goal and the member it is for can not be changed after creation.
*
* @throws AuthorizationException
*
* @operationId updateGoal
*/
public function update(Organization $organization, Goal $goal, GoalUpdateRequest $request, GoalsContract $access, GoalProgressService $progressService): GoalResource
{
if ($goal->type === GoalType::Personal) {
$this->checkPermission($organization, 'goals:update:own', $goal);
} else {
$this->checkPermission($organization, 'goals:update:organization-type', $goal);
}
$member = $this->member($organization);
if (! $access->canUpdateGoal($member, $goal)) {
throw new AuthorizationException;
}
if ($request->has('filters')) {
$goal->filters = $request->getFilters();
}
if ($request->has('name')) {
$goal->name = $request->getName();
}
if ($request->has('comparison')) {
$goal->comparison = $request->getComparison();
}
if ($request->has('target_seconds')) {
$goal->target_seconds = $request->getTargetSeconds();
}
if ($request->has('period')) {
$goal->period = $request->getPeriod();
}
if ($request->has('timezone')) {
$goal->timezone = $request->getTimezone();
}
if ($request->has('week_start')) {
$goal->week_start = $request->getWeekStart();
}
if ($request->has('is_archived')) {
$goal->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
$goal->save();
$goal->load('member.user');
return new GoalResource($goal, $progressService->getCurrentProgress($goal, Carbon::now()));
}
/**
* Delete goal
*
* @throws AuthorizationException
*
* @operationId deleteGoal
*/
public function destroy(Organization $organization, Goal $goal, GoalsContract $access): JsonResponse
{
if ($goal->type === GoalType::Personal) {
$this->checkPermission($organization, 'goals:delete:own', $goal);
} else {
$this->checkPermission($organization, 'goals:delete:organization-type', $goal);
}
$member = $this->member($organization);
if (! $access->canDeleteGoal($member, $goal)) {
throw new AuthorizationException;
}
$goal->delete();
return response()->json(null, 204);
}
}

View File

@@ -9,6 +9,7 @@ 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;
use App\Http\Resources\V1\Invitation\InvitationCollection; use App\Http\Resources\V1\Invitation\InvitationCollection;
use App\Http\Resources\V1\Invitation\InvitationResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Service\InvitationService; use App\Service\InvitationService;
@@ -29,6 +30,8 @@ class InvitationController extends Controller
/** /**
* List all invitations of an organization * List all invitations of an organization
* *
* @return InvitationCollection<InvitationResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getInvitations * @operationId getInvitations
@@ -85,8 +88,6 @@ class InvitationController extends Controller
/** /**
* Remove a pending invitation * Remove a pending invitation
* *
* This revokes the invitation: the link in the invitation email stops working. Find the invitation ID with `GET /organizations/{organization}/invitations`.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId removeInvitation * @operationId removeInvitation

View File

@@ -48,6 +48,8 @@ class MemberController extends Controller
/** /**
* List all members of an organization * List all members of an organization
* *
* @return MemberCollection<MemberResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getMembers * @operationId getMembers
@@ -143,9 +145,6 @@ class MemberController extends Controller
/** /**
* Merge one member into another * Merge one member into another
* *
* Only placeholder members (for example people created by an import) can be merged. All time entries and other data of the placeholder
* are reassigned to the member given in `member_id`, and the placeholder is removed. Find both member IDs with `GET /organizations/{organization}/members`.
*
* @throws AuthorizationException * @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember * @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws Throwable * @throws Throwable

View File

@@ -35,6 +35,8 @@ class ProjectController extends Controller
/** /**
* Get projects visible to the current user * Get projects visible to the current user
* *
* @return ProjectCollection<ProjectResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getProjects * @operationId getProjects

View File

@@ -36,6 +36,8 @@ class ProjectMemberController extends Controller
/** /**
* Get project members for project * Get project members for project
* *
* @return ProjectMemberCollection<ProjectMemberResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getProjectMembers * @operationId getProjectMembers

View File

@@ -10,6 +10,7 @@ use App\Http\Requests\V1\Report\ReportStoreRequest;
use App\Http\Requests\V1\Report\ReportUpdateRequest; use App\Http\Requests\V1\Report\ReportUpdateRequest;
use App\Http\Resources\V1\Report\DetailedReportResource; use App\Http\Resources\V1\Report\DetailedReportResource;
use App\Http\Resources\V1\Report\ReportCollection; use App\Http\Resources\V1\Report\ReportCollection;
use App\Http\Resources\V1\Report\ReportResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Report; use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto; use App\Service\Dto\ReportPropertiesDto;
@@ -34,6 +35,8 @@ class ReportController extends Controller
/** /**
* Get reports * Get reports
* *
* @return ReportCollection<ReportResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getReports * @operationId getReports
@@ -68,8 +71,6 @@ class ReportController extends Controller
/** /**
* Create report * Create report
* *
* A report is a saved set of filters. Set `is_public` to `true` to share it: the response then contains the `shareable_link` that can be opened without logging in.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId createReport * @operationId createReport

View File

@@ -29,6 +29,8 @@ class TagController extends Controller
/** /**
* Get tags * Get tags
* *
* @return TagCollection<TagResource>
*
* @operationId getTags * @operationId getTags
* *
* @throws AuthorizationException * @throws AuthorizationException

View File

@@ -51,6 +51,8 @@ class TaskController extends Controller
/** /**
* Get tasks * Get tasks
* *
* @return TaskCollection<TaskResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getTasks * @operationId getTasks

View File

@@ -109,13 +109,10 @@ class TimeEntryController extends Controller
/** /**
* Get time entries in organization * Get time entries in organization
* *
* Without a member filter this returns the time entries of all members of the organization (for users who may view all time entries, such as owners and admins), not only your own. * If you only need time entries for a specific user, you can filter by `member_id`.
* To get only your own time entries, pass your member ID as `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter. * Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
* *
* The `start` and `end` filters both apply to the start time of an entry, in UTC. Convert the user's local day boundaries to UTC first. * @return TimeEntryCollection<TimeEntryResource>
* Results are paginated with `limit` (default 100, max 500) and `offset`; check `meta.total` and fetch further pages when needed.
* To find the running timer, use `active=true` (or `GET /v1/users/me/time-entries/active`).
* *
* @throws AuthorizationException * @throws AuthorizationException
* *
@@ -194,9 +191,8 @@ class TimeEntryController extends Controller
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
if ($roundingType !== null && $roundingMinutes !== null) { if ($roundingType !== null && $roundingMinutes !== null) {
$select = array_diff($select, ['start', 'end']); $select = array_diff($select, ['start', 'end']);
// These SQL fragments are generated from a rounding enum and validated minute count. $select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start'); // @phpstan-ignore argument.type $select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end'); // @phpstan-ignore argument.type
} }
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
@@ -354,11 +350,6 @@ class TimeEntryController extends Controller
* The parameters `group` and `sub_group` allow you to group the time entries by different criteria. * The parameters `group` and `sub_group` allow you to group the time entries by different criteria.
* If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries. * If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.
* *
* Durations are returned in `seconds` (divide by 3600 for hours) and amounts in `cost` as cents in the organization's currency (divide by 100 for money).
* Filter by member with `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
* Array filters use the query format `client_ids[]=<id>`.
* Example: billable hours per client for a period: `group=client&billable=true&start=...&end=...`.
*
* @operationId getAggregatedTimeEntries * @operationId getAggregatedTimeEntries
* *
* @return array{ * @return array{
@@ -592,12 +583,6 @@ class TimeEntryController extends Controller
/** /**
* Create time entry * Create time entry
* *
* `billable` is not taken from the project. To match the web app, set it to the project's `is_billable` value (or `false` without a project).
*
* A member can only have one running time entry (an entry with `end` set to `null`). Creating a running entry while another one runs fails with `time_entry_still_running`.
* To start a new timer, first stop the running entry by updating its `end` to the current time, then create the new entry.
* To log past work, send both `start` and `end` (UTC). Create one entry per block of work.
*
* @throws AuthorizationException * @throws AuthorizationException
* @throws TimeEntryStillRunningApiException * @throws TimeEntryStillRunningApiException
* *
@@ -648,8 +633,6 @@ class TimeEntryController extends Controller
/** /**
* Update time entry * Update time entry
* *
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
*
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
* *
* @operationId updateTimeEntry * @operationId updateTimeEntry
@@ -718,10 +701,6 @@ class TimeEntryController extends Controller
/** /**
* Update multiple time entries * Update multiple time entries
* *
* Applies the same `changes` to every entry in `ids`. To find the IDs, list entries with `GET /organizations/{organization}/time-entries`
* (filtered by `member_id`, the project and the other criteria), then send their IDs here.
* When `changes.project_id` moves entries to another project, also set `changes.task_id` to a task of the new project or to `null`, because tasks belong to a project.
*
* @operationId updateMultipleTimeEntries * @operationId updateMultipleTimeEntries
* *
* @throws AuthorizationException * @throws AuthorizationException

View File

@@ -12,7 +12,7 @@ class TimeZoneController extends Controller
/** /**
* Get all timezones * Get all timezones
* *
* @response array{key: string}[] * @response object{key: string}[]
* *
* @operationId getTimezones * @operationId getTimezones
*/ */

View File

@@ -6,7 +6,7 @@ namespace App\Http\Controllers\Web;
use App\Models\Organization; use App\Models\Organization;
use Brick\Money\Currency; use Brick\Money\Currency;
use Brick\Money\IsoCurrencyProvider; use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -53,7 +53,7 @@ class OrganizationController extends Controller
], ],
'currencies' => array_map(function (Currency $currency): string { 'currencies' => array_map(function (Currency $currency): string {
return $currency->getName(); return $currency->getName();
}, IsoCurrencyProvider::getInstance()->getAvailableCurrencies()), }, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
'permissions' => [ 'permissions' => [
'canDeleteTeam' => $this->hasPermission($organization, 'organizations:delete'), 'canDeleteTeam' => $this->hasPermission($organization, 'organizations:delete'),
'canUpdateTeam' => $this->hasPermission($organization, 'organizations:update'), 'canUpdateTeam' => $this->hasPermission($organization, 'organizations:update'),

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Enums\Weekday;
use App\Service\Dto\UserAgentDto; use App\Service\Dto\UserAgentDto;
use App\Service\TimezoneService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -92,6 +94,8 @@ class UserProfileController extends Controller
$this->validateTwoFactorAuthenticationState($request); $this->validateTwoFactorAuthenticationState($request);
return Inertia::render('Profile/Show', [ return Inertia::render('Profile/Show', [
'timezones' => app(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), 'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
'sessions' => $this->sessions($request), 'sessions' => $this->sessions($request),
]); ]);

View File

@@ -11,7 +11,6 @@ use App\Http\Middleware\EnsureEmailIsVerified;
use App\Http\Middleware\ForceHttps; use App\Http\Middleware\ForceHttps;
use App\Http\Middleware\ForceJsonResponse; use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\PreventRequestForgery;
use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\ShareInertiaData; use App\Http\Middleware\ShareInertiaData;
@@ -19,6 +18,7 @@ use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts; use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies; use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature; use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth; use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize; use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\RequirePassword; use Illuminate\Auth\Middleware\RequirePassword;
@@ -68,7 +68,7 @@ class Kernel extends HttpKernel
AddQueuedCookiesToResponse::class, AddQueuedCookiesToResponse::class,
StartSession::class, StartSession::class,
ShareErrorsFromSession::class, ShareErrorsFromSession::class,
PreventRequestForgery::class, VerifyCsrfToken::class,
SubstituteBindings::class, SubstituteBindings::class,
HandleInertiaRequests::class, HandleInertiaRequests::class,
ShareInertiaData::class, ShareInertiaData::class,

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Filament\Http\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class AuthenticateFilamentPanel extends Middleware
{
/**
* The admin panel has no login page of its own (it shares the app's
* session-based login), so Filament::getLoginUrl() always returns null.
* Fall back to the app's login route so guests are redirected instead
* of receiving an empty 401 response.
*/
protected function redirectTo($request): ?string
{
return parent::redirectTo($request) ?? ($request->expectsJson() ? null : route('login'));
}
}

View File

@@ -42,7 +42,6 @@ class HandleInertiaRequests extends Middleware
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing'); $hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services'); $hasServices = Module::has('Services') && Module::isEnabled('Services');
$hasGoals = Module::has('Goals') && Module::isEnabled('Goals');
/** @var BillingContract $billing */ /** @var BillingContract $billing */
$billing = app(BillingContract::class); $billing = app(BillingContract::class);
@@ -53,13 +52,11 @@ class HandleInertiaRequests extends Middleware
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing, 'has_invoicing_extension' => $hasInvoicing,
'has_services_extension' => $hasServices, 'has_services_extension' => $hasServices,
'has_goals_extension' => $hasGoals,
'billing' => $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(),
'is_blocked' => $billing->isBlocked($currentOrganization), 'is_blocked' => $billing->isBlocked($currentOrganization),
'goal_limit' => $billing->getGoalLimit($currentOrganization),
] : null, ] : null,
'flash' => [ 'flash' => [
'message' => fn () => $request->session()->get('message'), 'message' => fn () => $request->session()->get('message'),

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery as Middleware;
class PreventRequestForgery extends Middleware
{
/**
* The URIs that should be excluded.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -1,144 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\Role;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\Dto\GoalFiltersDto;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\ProhibitedIf;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* Shared validation rules for the filters of a goal.
*
* @property Organization $organization Organization from model binding
*/
trait GoalFilterRules
{
/**
* @param \Closure(): bool $forEveryMember Whether the goal counts every member instead of one member, evaluated during validation
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure|ProhibitedIf>>
*/
protected function filterRules(\Closure $forEveryMember): array
{
return [
'filters' => [
'sometimes',
'array',
],
// Filter by member IDs, member IDs are OR combined. Only for goals that count every member, null means every member.
'filters.member_ids' => [
'nullable',
'array',
// "prohibited" still lets null and an empty array through, both mean "no restriction"
Rule::prohibitedIf(fn (): bool => ! $forEveryMember()),
],
'filters.member_ids.*' => [
'string',
'distinct',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization')
->where('role', '!=', Role::Placeholder->value);
})->uuid(),
],
// Filter by project IDs, project IDs are OR combined, "none" matches entries without a project
'filters.project_ids' => [
'nullable',
'array',
],
'filters.project_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Project::class),
],
// Filter by task IDs, task IDs are OR combined, "none" matches entries without a task
'filters.task_ids' => [
'nullable',
'array',
],
'filters.task_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Task::class),
],
// Filter by tag IDs, tag IDs are OR combined, "none" matches entries without tags
'filters.tag_ids' => [
'nullable',
'array',
],
'filters.tag_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Tag::class),
],
'filters.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
// Filter by client IDs, client IDs are OR combined, "none" matches entries without a client
'filters.client_ids' => [
'nullable',
'array',
],
'filters.client_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Client::class),
],
// Filter by billable status, null means both
'filters.billable' => [
'nullable',
'boolean',
],
// Filter by time entry type, null means both
'filters.time_entry_type' => [
'nullable',
'string',
Rule::enum(TimeEntryType::class),
],
];
}
/**
* @param class-string<Project|Task|Tag|Client> $modelClass
*/
private function idOrNoneExistsInOrganization(string $modelClass): \Closure
{
return function (string $attribute, mixed $value, \Closure $fail) use ($modelClass): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make($modelClass, null, function (Builder $builder): Builder {
/** @var Builder<Project|Task|Tag|Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
};
}
public function getFilters(): GoalFiltersDto
{
$filters = new GoalFiltersDto;
$filters->setMemberIds($this->input('filters.member_ids'));
$filters->setProjectIds($this->input('filters.project_ids'));
$filters->setTaskIds($this->input('filters.task_ids'));
$filters->setTagIds($this->input('filters.tag_ids'));
$filters->tagMatchType = $this->input('filters.tag_match_type') !== null ? TagMatchType::from($this->input('filters.tag_match_type')) : null;
$filters->setClientIds($this->input('filters.client_ids'));
$filters->billable = $this->input('filters.billable') !== null ? (bool) $this->input('filters.billable') : null;
$filters->timeEntryType = $this->input('filters.time_entry_type') !== null ? TimeEntryType::from($this->input('filters.time_entry_type')) : null;
return $filters;
}
}

View File

@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\GoalType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class GoalIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
*/
public function rules(): array
{
return [
// Only return goals of this type (personal, organization)
'type' => [
'nullable',
'string',
Rule::enum(GoalType::class),
],
// Filter by archived status, "true" only archived, "false" only not archived (default), "all" both
'archived' => [
'nullable',
'string',
'in:true,false,all',
],
'page' => [
'nullable',
'integer',
'min:1',
'max:2147483647',
],
];
}
public function getType(): ?GoalType
{
if ($this->input('type') === null) {
return null;
}
return GoalType::from($this->input('type'));
}
public function getArchivedFilter(): string
{
return (string) $this->input('archived', 'false');
}
}

View File

@@ -1,168 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalType;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\Rules\ProhibitedIf;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class GoalStoreRequest extends BaseFormRequest
{
use GoalFilterRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|In|ProhibitedIf|\Closure>>
*/
public function rules(): array
{
$memberExistsInOrganization = ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization')
->where('role', '!=', Role::Placeholder->value);
})->uuid();
return array_merge([
'name' => [
'required',
'string',
'max:255',
],
// personal: a goal for yourself that nobody else can see, organization: a goal of the organization for a member or for every member. Organization goals require the team goals extension.
'type' => [
'required',
'string',
Rule::enum(GoalType::class),
],
// Whether the goal is reached when "at least" or "less than" the target time is tracked in the period
'comparison' => [
'required',
'string',
Rule::enum(GoalComparison::class),
],
// Target time in seconds
'target_seconds' => [
'required',
'integer',
'min:1',
'max:2147483647',
],
// The recurring time frame in which the target has to be reached
'period' => [
'required',
'string',
Rule::enum(GoalPeriod::class),
],
// ID of the member whose time entries count towards the goal, defaults to the current member. Send null for a goal that counts every member (organization goals only).
'member_id' => $this->input('type') === GoalType::Personal->value
// A personal goal is always for the current member, it can not count every member
? [
'sometimes',
'filled',
'string',
Rule::in([$this->currentMemberId()]),
]
: [
'nullable',
'string',
$memberExistsInOrganization,
],
// Timezone that defines the periods of the goal, defaults to the timezone of the current user
'timezone' => [
'nullable',
'timezone:all',
],
// Week start that defines weekly periods, defaults to the week start of the current user
'week_start' => [
'nullable',
'string',
Rule::enum(Weekday::class),
],
], $this->filterRules(
// Without a member_id the goal is for the current member
fn (): bool => $this->has('member_id') && $this->input('member_id') === null,
));
}
private function currentMemberId(): ?string
{
/** @var string|null $memberId */
$memberId = Member::query()
->whereBelongsTo($this->organization, 'organization')
->where('user_id', $this->user()?->getKey())
->value('id');
return $memberId;
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getType(): GoalType
{
return GoalType::from($this->input('type'));
}
public function getComparison(): GoalComparison
{
return GoalComparison::from($this->input('comparison'));
}
public function getTargetSeconds(): int
{
return (int) $this->input('target_seconds');
}
public function getPeriod(): GoalPeriod
{
return GoalPeriod::from($this->input('period'));
}
public function hasMemberId(): bool
{
return $this->has('member_id');
}
public function getMemberId(): ?string
{
return $this->input('member_id');
}
public function getTimezone(): ?string
{
if (! $this->has('timezone') || $this->input('timezone') === null) {
return null;
}
return (string) $this->input('timezone');
}
public function getWeekStart(): ?Weekday
{
if (! $this->has('week_start') || $this->input('week_start') === null) {
return null;
}
return Weekday::from($this->input('week_start'));
}
}

View File

@@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Goal;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\ProhibitedIf;
/**
* @property Organization $organization Organization from model binding
*/
class GoalUpdateRequest extends BaseFormRequest
{
use GoalFilterRules;
/**
* Get the validation rules that apply to the request.
* The type of a goal and the member it is for can not be changed after creation.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|ProhibitedIf|\Closure>>
*/
public function rules(): array
{
/** @var Goal $goal */
$goal = $this->route('goal');
return array_merge([
'name' => [
'sometimes',
'string',
'max:255',
],
// Whether the goal is reached when "at least" or "less than" the target time is tracked in the period
'comparison' => [
'sometimes',
'string',
Rule::enum(GoalComparison::class),
],
// Target time in seconds
'target_seconds' => [
'sometimes',
'integer',
'min:1',
'max:2147483647',
],
// The recurring time frame in which the target has to be reached
'period' => [
'sometimes',
'string',
Rule::enum(GoalPeriod::class),
],
// Timezone that defines the periods of the goal
'timezone' => [
'sometimes',
'timezone:all',
],
// Week start that defines weekly periods
'week_start' => [
'sometimes',
'string',
Rule::enum(Weekday::class),
],
// Archived goals are hidden from the goal list by default, their progress is still calculated
'is_archived' => [
'sometimes',
'boolean',
],
// Only allowed if it matches the current type of the goal
'type' => [
function (string $attribute, mixed $value, \Closure $fail) use ($goal): void {
if ($value !== $goal->type->value) {
$fail('The '.$attribute.' of a goal can not be changed.');
}
},
],
// Only allowed if it matches the current member of the goal
'member_id' => [
function (string $attribute, mixed $value, \Closure $fail) use ($goal): void {
if ($value !== $goal->member_id) {
$fail('The '.$attribute.' of a goal can not be changed.');
}
},
],
], $this->filterRules(fn (): bool => $goal->member_id === null));
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getComparison(): GoalComparison
{
return GoalComparison::from($this->input('comparison'));
}
public function getTargetSeconds(): int
{
return (int) $this->input('target_seconds');
}
public function getPeriod(): GoalPeriod
{
return GoalPeriod::from($this->input('period'));
}
public function getTimezone(): string
{
return (string) $this->input('timezone');
}
public function getWeekStart(): Weekday
{
return Weekday::from($this->input('week_start'));
}
public function getIsArchived(): bool
{
return (bool) $this->input('is_archived');
}
}

View File

@@ -27,7 +27,6 @@ class MemberUpdateRequest extends BaseFormRequest
'string', 'string',
Rule::enum(Role::class), Rule::enum(Role::class),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -36,7 +36,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'string', 'string',
new CurrencyRule, new CurrencyRule,
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -55,7 +55,6 @@ class ProjectStoreRequest extends BaseFormRequest
'required', 'required',
'boolean', 'boolean',
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -68,7 +68,6 @@ class ProjectUpdateRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge([ 'billable_rate' => array_merge([
'nullable', 'nullable',
], ],

View File

@@ -31,7 +31,6 @@ class ProjectMemberStoreRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -21,7 +21,6 @@ class ProjectMemberUpdateRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Filter by member ID. Without it, users who may view all time entries (owners, admins) get the entries of every member; pass your own member ID (from GET /v1/users/me/memberships) to get only yours // Filter by member ID
'member_id' => [ 'member_id' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
@@ -155,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string', 'string',
Rule::enum(TimeEntryType::class), Rule::enum(TimeEntryType::class),
], ],
// Limit the number of returned time entries (default: 100) // Limit the number of returned time entries (default: 150)
'limit' => [ 'limit' => [
'integer', 'integer',
'min:1', 'min:1',

View File

@@ -32,7 +32,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// ID of the organization member that the time entry should belong to (a member ID from GET /v1/users/me/memberships or the members list, not a user ID) // ID of the organization member that the time entry should belong to
'member_id' => [ 'member_id' => [
'required', 'required',
'string', 'string',
@@ -86,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
'after_or_equal:start', 'after_or_equal:start',
], ],
// Whether time entry is billable. Not derived from the project: set it to the project's is_billable value to match the web app // Whether time entry is billable
'billable' => [ 'billable' => [
'required', 'required',
'boolean', 'boolean',

View File

@@ -13,11 +13,9 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Carbon\Exceptions\InvalidFormatException;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ConditionalRules; use Illuminate\Validation\ConditionalRules;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -92,32 +90,12 @@ class TimeEntryUpdateRequest extends BaseFormRequest
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") // Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'start' => [ 'start' => [
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
// If the payload does not contain an end, the start needs to be validated against the persisted end
if ($this->has('end') || $timeEntry?->end === null) {
return;
}
$start = $this->parseDate($value);
if ($start !== null && $start->gt($timeEntry->end)) {
$fail('The start field must be a date before or equal to end.');
}
},
], ],
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") // End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'end' => [ 'end' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
'after_or_equal:start', 'after_or_equal:start',
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
// If the payload does not contain a start, the end needs to be validated against the persisted start
if ($this->has('start') || $timeEntry === null) {
return;
}
$end = $this->parseDate($value);
if ($end !== null && $end->lt($timeEntry->start)) {
$fail('The end field must be a date after or equal to start.');
}
},
], ],
// Whether time entry is billable // Whether time entry is billable
'billable' => [ 'billable' => [
@@ -159,16 +137,4 @@ class TimeEntryUpdateRequest extends BaseFormRequest
], ],
]; ];
} }
private function parseDate(mixed $value): ?Carbon
{
if (! is_string($value)) {
return null;
}
try {
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $value, 'UTC');
} catch (InvalidFormatException) {
return null;
}
}
} }

View File

@@ -43,10 +43,9 @@ class UserUpdateRequest extends BaseFormRequest
'email' => [ 'email' => [
'email:rfc,strict', 'email:rfc,strict',
'max:255', 'max:255',
UniqueEloquent::make(User::class, 'email')->query(function (Builder $query) { UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */ /** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false) return $query->where('is_placeholder', '=', false);
->whereKeyNot($this->user->getKey());
}), }),
], ],
'photo' => [ 'photo' => [

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
interface PaginatedResourceCollection {}

View File

@@ -21,15 +21,15 @@ class ApiTokenResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of the API token, this ID is NOT a UUID */ /** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name of the API token */ /** @var string $name Name of the API token */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Whether the API token is revoked */ /** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked, 'revoked' => $this->resource->revoked,
/** List of scopes that the API token has */ /** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes, 'scopes' => $this->resource->scopes,
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ /** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ /** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at), 'expires_at' => $this->formatDateTime($this->resource->expires_at),

View File

@@ -29,15 +29,15 @@ class ApiTokenWithAccessTokenResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of the API token, this ID is NOT a UUID */ /** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name of the API token */ /** @var string $name Name of the API token */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Whether the API token is revoked */ /** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked, 'revoked' => $this->resource->revoked,
/** List of scopes that the API token has */ /** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes, 'scopes' => $this->resource->scopes,
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ /** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ /** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at), 'expires_at' => $this->formatDateTime($this->resource->expires_at),

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Client; namespace App\Http\Resources\V1\Client;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class ClientCollection extends ResourceCollection class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,15 +21,15 @@ class ClientResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID */ /** @var string $id ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Whether the client is archived */ /** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived, 'is_archived' => $this->resource->is_archived,
/** When the tag was created */ /** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** When the tag was last updated */ /** @var string $updated_at When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Goal;
use App\Models\Goal;
use App\Service\Dto\GoalProgressDto;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class GoalCollection extends ResourceCollection
{
/**
* @var array<string, GoalProgressDto>
*/
private array $progressByGoalId;
/**
* @param array<string, GoalProgressDto> $progressByGoalId
*/
public function __construct($resource, array $progressByGoalId)
{
parent::__construct($resource);
$this->progressByGoalId = $progressByGoalId;
}
protected function collects(): ?string
{
return null;
}
/**
* Transform the resource collection into an array.
*
* @return array<array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>>
*/
public function toArray(Request $request): array
{
return $this->collection->map(function (Goal $goal) use ($request): array {
return (new GoalResource($goal, $this->progressByGoalId[$goal->getKey()]))
->toArray($request);
})->all();
}
}

View File

@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Goal;
use App\Http\Resources\V1\BaseResource;
use App\Models\Goal;
use App\Service\Dto\GoalProgressDto;
use Illuminate\Http\Request;
/**
* @property Goal $resource
*/
class GoalResource extends BaseResource
{
private GoalProgressDto $progress;
public function __construct(Goal $resource, GoalProgressDto $progress)
{
parent::__construct($resource);
$this->progress = $progress;
}
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>
*/
public function toArray(Request $request): array
{
return [
/** ID of the goal */
'id' => $this->resource->id,
/** Name */
'name' => $this->resource->name,
/** personal: a goal a member set for themselves, organization: a goal of the organization (team goals extension) */
'type' => $this->resource->type->value,
/** Whether the goal is reached with "at least" or "less than" the target time (at_least, less_than) */
'comparison' => $this->resource->comparison->value,
/** Target time in seconds */
'target_seconds' => $this->resource->target_seconds,
/** Recurring time frame (day, week, month) */
'period' => $this->resource->period->value,
/** ID of the member whose time entries count towards the goal, null if every member counts */
'member_id' => $this->resource->member_id,
/** Name of the member whose time entries count towards the goal, null if every member counts */
'member_name' => $this->resource->member?->user->name,
/** Timezone that defines the periods of the goal */
'timezone' => $this->resource->timezone,
/** Week start that defines weekly periods */
'week_start' => $this->resource->week_start->value,
/** Whether the goal is archived */
'is_archived' => $this->resource->is_archived,
'filters' => [
/** @var array<string>|null $member_ids Filter by member IDs, member IDs are OR combined, only for goals that count every member */
'member_ids' => $this->resource->filters->memberIds?->toArray(),
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
'project_ids' => $this->resource->filters->projectIds?->toArray(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->filters->taskIds?->toArray(),
/** @var array<string>|null $tag_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->filters->tagIds?->toArray(),
/** Tag match type (contains, not_contains) */
'tag_match_type' => $this->resource->filters->tagMatchType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->filters->clientIds?->toArray(),
/** Filter by billable status */
'billable' => $this->resource->filters->billable,
/** Filter by time entry type (work, break) */
'time_entry_type' => $this->resource->filters->timeEntryType?->value,
],
'progress' => [
/** Start of the current period (inclusive) */
'period_start' => $this->formatDateTime($this->progress->periodStart),
/** End of the current period (exclusive) */
'period_end' => $this->formatDateTime($this->progress->periodEnd),
/** Seconds tracked in the current period that match the filters, incl. the running time entry */
'tracked_seconds' => $this->progress->trackedSeconds,
/** Status in the current period (in_progress, achieved, on_track, exceeded) */
'status' => $this->progress->status->value,
],
/** Date when the goal was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** Date when the goal was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}
}

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Invitation; namespace App\Http\Resources\V1\Invitation;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class InvitationCollection extends ResourceCollection class InvitationCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,11 +21,11 @@ class InvitationResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of the invitation */ /** @var string $id ID of the invitation */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Email */ /** @var string $email Email */
'email' => $this->resource->email, 'email' => $this->resource->email,
/** Role */ /** @var string $role Role */
'role' => $this->resource->role, 'role' => $this->resource->role,
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Member; namespace App\Http\Resources\V1\Member;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class MemberCollection extends ResourceCollection class MemberCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -22,19 +22,19 @@ class MemberResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of membership */ /** @var string $id ID of membership */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** ID of user */ /** @var string $id ID of user */
'user_id' => $this->resource->user->id, 'user_id' => $this->resource->user->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->user->name, 'name' => $this->resource->user->name,
/** Email */ /** @var string $email Email */
'email' => $this->resource->user->email, 'email' => $this->resource->user->email,
/** Role */ /** @var string $role Role */
'role' => $this->resource->role, 'role' => $this->resource->role,
/** Placeholder user for imports, user might not really exist and does not know about this placeholder membership */ /** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
'is_placeholder' => $this->resource->user->is_placeholder, 'is_placeholder' => $this->resource->user->is_placeholder,
/** Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate, 'billable_rate' => $this->resource->billable_rate,
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Member; namespace App\Http\Resources\V1\Member;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class PersonalMembershipCollection extends ResourceCollection class PersonalMembershipCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,17 +21,17 @@ class PersonalMembershipResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of membership */ /** @var string $id ID of membership */
'id' => $this->resource->id, 'id' => $this->resource->id,
'organization' => [ 'organization' => [
/** ID of organization */ /** @var string $id ID of organization */
'id' => $this->resource->organization->id, 'id' => $this->resource->organization->id,
/** Name of organization */ /** @var string $name Name of organization */
'name' => $this->resource->organization->name, 'name' => $this->resource->organization->name,
/** Currency code (ISO 4217) of organization */ /** @var string $currency Currency code (ISO 4217) of organization */
'currency' => $this->resource->organization->currency, 'currency' => $this->resource->organization->currency,
], ],
/** Role */ /** @var string $role Role */
'role' => $this->resource->role, 'role' => $this->resource->role,
]; ];
} }

View File

@@ -43,23 +43,23 @@ class OrganizationResource extends BaseResource
$currencyService = app(CurrencyService::class); $currencyService = app(CurrencyService::class);
return [ return [
/** ID */ /** @var string $id ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Personal organizations automatically created after registration */ /** @var bool $color Personal organizations automatically created after registration */
'is_personal' => $this->resource->personal_team, 'is_personal' => $this->resource->personal_team,
/** Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** Can members of the organization with role "employee" see the billable rates */ /** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates, 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */ /** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks, 'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** Prevent creating overlapping time entries (only new entries) */ /** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries, 'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** Whether members of the organization can track breaks */ /** @var bool $breaks_enabled Whether members of the organization can track breaks */
'breaks_enabled' => $this->resource->breaks_enabled, 'breaks_enabled' => $this->resource->breaks_enabled,
/** 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 */ /** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency), 'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),

View File

@@ -4,11 +4,12 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Project; namespace App\Http\Resources\V1\Project;
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;
class ProjectCollection extends ResourceCollection class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
private bool $showBillableRates; private bool $showBillableRates;

View File

@@ -30,25 +30,25 @@ class ProjectResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of project */ /** @var string $id ID of project */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name of project */ /** @var string $name Name of project */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Color of project */ /** @var string $color Color of project */
'color' => $this->resource->color, 'color' => $this->resource->color,
/** @var string|null $client_id ID of client */ /** @var string|null $client_id ID of client */
'client_id' => $this->resource->client_id, 'client_id' => $this->resource->client_id,
/** Whether the client is archived */ /** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived, 'is_archived' => $this->resource->is_archived,
/** Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** Project time entries billable default */ /** @var bool $is_billable Project time entries billable default */
'is_billable' => $this->resource->is_billable, 'is_billable' => $this->resource->is_billable,
/** Estimated time in seconds */ /** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time, 'estimated_time' => $this->resource->estimated_time,
/** Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */ /** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time, 'spent_time' => $this->resource->spent_time,
/** Whether the project is public */ /** @var bool $is_public Whether the project is public */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\ProjectMember; namespace App\Http\Resources\V1\ProjectMember;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class ProjectMemberCollection extends ResourceCollection class ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,13 +21,13 @@ class ProjectMemberResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of project member */ /** @var string $id ID of project member */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate, 'billable_rate' => $this->resource->billable_rate,
/** ID of the organization member */ /** @var string $member_id ID of the organization member */
'member_id' => $this->resource->member_id, 'member_id' => $this->resource->member_id,
/** ID of the project */ /** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
]; ];
} }

View File

@@ -21,36 +21,36 @@ class DetailedReportResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of the report */ /** @var string $id ID of the report */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Description */ /** @var string|null $email Description */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** 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->formatDateTime($this->resource->public_until),
/** @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' => [
/** Type of first grouping */ /** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value, 'group' => $this->resource->properties->group->value,
/** Type of second grouping */ /** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value, 'sub_group' => $this->resource->properties->subGroup->value,
/** 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,
/** Start date of the report */ /** @var string $start Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start), 'start' => $this->formatDateTime($this->resource->properties->start),
/** End date of the report */ /** @var string $end End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end), 'end' => $this->formatDateTime($this->resource->properties->end),
/** 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 */
'member_ids' => $this->resource->properties->memberIds?->toArray(), 'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** Filter by billable status */ /** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->properties->billable, 'billable' => $this->resource->properties->billable,
/** Filter by time entry type */ /** @var string|null $time_entry_type Filter by time entry type */
'time_entry_type' => $this->resource->properties->timeEntryType?->value, 'time_entry_type' => $this->resource->properties->timeEntryType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */ /** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(), 'client_ids' => $this->resource->properties->clientIds?->toArray(),
@@ -58,18 +58,18 @@ class DetailedReportResource extends BaseResource
'project_ids' => $this->resource->properties->projectIds?->toArray(), 'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */ /** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(), 'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** Tag match type */ /** @var string|null $tag_match_type Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value, 'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */ /** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(), 'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** Rounding type for time entries */ /** @var string|null $rounding_type Rounding type for time entries */
'rounding_type' => $this->resource->properties->roundingType?->value, 'rounding_type' => $this->resource->properties->roundingType?->value,
/** Rounding minutes for time entries */ /** @var int|null $rounding_minutes Rounding minutes for time entries */
'rounding_minutes' => $this->resource->properties->roundingMinutes, 'rounding_minutes' => $this->resource->properties->roundingMinutes,
], ],
/** Date when the report was created */ /** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** 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->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -73,13 +73,13 @@ class DetailedWithDataReportResource extends BaseResource
$currencyService = app(CurrencyService::class); $currencyService = app(CurrencyService::class);
return [ return [
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** 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->formatDateTime($this->resource->public_until),
/** 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 */ /** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->organization->number_format->value, 'number_format' => $this->resource->organization->number_format->value,
@@ -94,15 +94,15 @@ class DetailedWithDataReportResource extends BaseResource
/** @var TimeFormat $time_format Time format */ /** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->organization->time_format->value, 'time_format' => $this->resource->organization->time_format->value,
'properties' => [ 'properties' => [
/** Type of first grouping */ /** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value, 'group' => $this->resource->properties->group->value,
/** Type of second grouping */ /** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value, 'sub_group' => $this->resource->properties->subGroup->value,
/** 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,
/** Start date of the report */ /** @var string $start Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start), 'start' => $this->formatDateTime($this->resource->properties->start),
/** End date of the report */ /** @var string $end End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end), 'end' => $this->formatDateTime($this->resource->properties->end),
], ],
/** @var array{ /** @var array{

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Report; namespace App\Http\Resources\V1\Report;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class ReportCollection extends ResourceCollection class ReportCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,21 +21,21 @@ class ReportResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of the report */ /** @var string $id ID of the report */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Description */ /** @var string|null $email Description */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** 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->formatDateTime($this->resource->public_until),
/** @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(),
/** Date when the report was created */ /** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** 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->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Tag; namespace App\Http\Resources\V1\Tag;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class TagCollection extends ResourceCollection class TagCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,13 +21,13 @@ class TagResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID */ /** @var string $id ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** When the tag was created */ /** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** When the tag was last updated */ /** @var string $updated_at When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Task; namespace App\Http\Resources\V1\Task;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class TaskCollection extends ResourceCollection class TaskCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -22,21 +22,21 @@ class TaskResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID */ /** @var string $id ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Whether the task is done */ /** @var bool $is_done Whether the task is done */
'is_done' => $this->resource->is_done, 'is_done' => $this->resource->is_done,
/** ID of the project */ /** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
/** Estimated time in seconds */ /** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time, 'estimated_time' => $this->resource->estimated_time,
/** Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */ /** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time, 'spent_time' => $this->resource->spent_time,
/** When the tag was created */ /** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** When the tag was last updated */ /** @var string $updated_at When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\TimeEntry; namespace App\Http\Resources\V1\TimeEntry;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class TimeEntryCollection extends ResourceCollection class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,10 +21,10 @@ class TimeEntryResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of time entry */ /** @var string $id ID of time entry */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** /**
* Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) * @var string $start Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
*/ */
'start' => $this->formatDateTime($this->resource->start), 'start' => $this->formatDateTime($this->resource->start),
/** /**
@@ -35,19 +35,19 @@ class TimeEntryResource extends BaseResource
'duration' => (int) $this->resource->getDuration()?->totalSeconds, 'duration' => (int) $this->resource->getDuration()?->totalSeconds,
/** @var string|null $description Description of time entry */ /** @var string|null $description Description of time entry */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** ID of task */ /** @var string|null $task_id ID of task */
'task_id' => $this->resource->task_id, 'task_id' => $this->resource->task_id,
/** ID of project */ /** @var string|null $project_id ID of project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
/** ID of organization */ /** @var string $organization_id ID of organization */
'organization_id' => $this->resource->organization_id, 'organization_id' => $this->resource->organization_id,
/** ID of user */ /** @var string $user_id ID of user */
'user_id' => $this->resource->user_id, 'user_id' => $this->resource->user_id,
/** List of tag IDs */ /** @var array<string> $tags List of tag IDs */
'tags' => $this->resource->tags ?? [], 'tags' => $this->resource->tags ?? [],
/** Whether time entry is billable */ /** @var bool $billable Whether time entry is billable */
'billable' => $this->resource->billable, 'billable' => $this->resource->billable,
/** Type of the time entry (`work` time or a `break`) */ /** @var string $type Type of the time entry (`work` time or a `break`) */
'type' => $this->resource->type->value, 'type' => $this->resource->type->value,
]; ];
} }

View File

@@ -22,21 +22,21 @@ class UserResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of user */ /** @var string $id ID of user */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name of user */ /** @var string $name Name of user */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Email of user */ /** @var string $email Email of user */
'email' => $this->resource->email, 'email' => $this->resource->email,
/** Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */ /** @var string|null $pending_email Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
'pending_email' => $this->resource->pending_email, 'pending_email' => $this->resource->pending_email,
/** Profile photo URL */ /** @var string $profile_photo_url Profile photo URL */
'profile_photo_url' => $this->resource->profile_photo_url, 'profile_photo_url' => $this->resource->profile_photo_url,
/** Timezone (f.e. Europe/Berlin or America/New_York) */ /** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */
'timezone' => $this->resource->timezone, 'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */ /** @var Weekday $week_start Starting day of the week */
'week_start' => $this->resource->week_start->value, 'week_start' => $this->resource->week_start->value,
/** Whether to email the user when a time entry has been running for more than 8 hours */ /** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email, 'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
]; ];
} }

View File

@@ -4,19 +4,15 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\AuditableWithoutOwner;
use Database\Factories\AuditFactory; use Database\Factories\AuditFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Models\Audit as PackageAuditModel; use OwenIt\Auditing\Models\Audit as PackageAuditModel;
/** /**
* @property int $id * @property int $id
* @property string|null $actor_type * @property string|null $user_type
* @property string|null $actor_id * @property string|null $user_id
* @property string $event * @property string $event
* @property string $auditable_type * @property string $auditable_type
* @property string $auditable_id * @property string $auditable_id
@@ -26,71 +22,13 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string|null $ip_address * @property string|null $ip_address
* @property string|null $user_agent * @property string|null $user_agent
* @property string|null $tags * @property string|null $tags
* @property string|null $owner_user_id
* @property string|null $owner_organization_id
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read User|null $ownerUser
* @property-read Organization|null $ownerOrganization
* *
* @method static AuditFactory factory() * @method static AuditFactory factory()
* @method static Builder<Audit> whereMissingOwner()
*/ */
class Audit extends PackageAuditModel class Audit extends PackageAuditModel
{ {
/** @use HasFactory<AuditFactory> */ /** @use HasFactory<AuditFactory> */
use HasFactory; use HasFactory;
/**
* @return BelongsTo<User, $this>
*/
public function ownerUser(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
/**
* @return BelongsTo<Organization, $this>
*/
public function ownerOrganization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'owner_organization_id');
}
/**
* Whether audits of the given auditable type intentionally have no owner (see AuditableWithoutOwner).
*/
public static function isAuditableTypeWithoutOwner(string $auditableType): bool
{
$modelClass = Relation::getMorphedModel($auditableType) ?? $auditableType;
return is_subclass_of($modelClass, AuditableWithoutOwner::class);
}
/**
* Auditable types (morph aliases) whose audits intentionally have no owner (see AuditableWithoutOwner).
*
* @return array<int, string>
*/
public static function getAuditableTypesWithoutOwner(): array
{
return collect(Relation::morphMap())
->filter(fn (string $modelClass): bool => is_subclass_of($modelClass, AuditableWithoutOwner::class))
->keys()
->values()
->all();
}
/**
* Audits that have neither an owner organization nor an owner user, although their auditable type should have one.
* These are audits whose owner no longer exists or could not be determined (yet).
*
* @param Builder<Audit> $builder
*/
public function scopeWhereMissingOwner(Builder $builder): void
{
$builder->whereNull('owner_organization_id')
->whereNull('owner_user_id')
->whereNotIn('auditable_type', self::getAuditableTypesWithoutOwner());
}
} }

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Marks an auditable model whose audits are owned by the owner of its parent model,
* because the model itself has no organization_id (for example a project member belongs to an organization via its project).
* The parent model has to have an organization_id column.
* This is used to set the owner of new audits (CustomAuditable) and to backfill the owner of existing audits.
*/
interface AuditableThroughParent
{
/**
* @return BelongsTo<covariant Model, covariant Model>
*/
public function getAuditParentRelation(): BelongsTo;
}

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
/**
* Marks an auditable model whose audits intentionally have no owner (neither an organization nor a user),
* either because the model belongs to neither of them, or because the model and its audits have to be kept
* when its organization or user is deleted (for example billing records).
* Audits of these models are not deleted together with an organization or user,
* and are not considered as missing an owner (for example by the audit backfill command).
*/
interface AuditableWithoutOwner {}

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Models\Concerns; namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable; use OwenIt\Auditing\Auditable;
trait CustomAuditable trait CustomAuditable
@@ -21,92 +19,4 @@ trait CustomAuditable
{ {
$this->auditEvents = []; $this->auditEvents = [];
} }
/**
* The organization that owns the audited model.
* The audits of the model are deleted (via foreign key cascade) when the organization is deleted.
*/
public function getAuditOwnerOrganizationId(): ?string
{
if ($this instanceof AuditableThroughParent) {
$relation = $this->getAuditParentRelation();
$parentId = $this->getAttributes()[$relation->getForeignKeyName()] ?? null;
if ($parentId === null) {
return null;
}
// Note: The loaded parent is only used if it still matches the foreign key and was loaded with the organization
$relationName = $relation->getRelationName();
if ($this->relationLoaded($relationName)) {
$parent = $this->getRelation($relationName);
if ($parent instanceof Model
&& $parent->getAttribute($relation->getOwnerKeyName()) === $parentId
&& array_key_exists('organization_id', $parent->getAttributes())) {
/** @var string|null $organizationId */
$organizationId = $parent->getAttributes()['organization_id'];
return $organizationId;
}
}
/** @var string|null $organizationId */
$organizationId = $relation->getRelated()->newQuery()
->toBase()
->where($relation->getOwnerKeyName(), $parentId)
->value('organization_id');
return $organizationId;
}
return $this->getAttributes()['organization_id'] ?? null;
}
/**
* The user that owns the audited model.
* The audits of the model are deleted (via foreign key cascade) when the user is deleted.
*/
public function getAuditOwnerUserId(): ?string
{
return null;
}
/**
* Models that are the owner of their own audits can not record the deletion audit,
* since the audit would reference the already deleted model and therefore violate the foreign key.
*/
protected function isAuditOwnerOfItself(): bool
{
return false;
}
/**
* @return array<int|string, string>
*/
public function getAuditEvents(): array
{
$events = $this->auditEvents ?? Config::get('audit.events', [
'created',
'updated',
'deleted',
'restored',
]);
if ($this->isAuditOwnerOfItself()) {
$events = array_filter($events, fn (string $value, int|string $key): bool => (is_int($key) ? $value : $key) !== 'deleted', ARRAY_FILTER_USE_BOTH);
}
return $events;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function transformAudit(array $data): array
{
$data['owner_organization_id'] = $this->getAuditOwnerOrganizationId();
$data['owner_user_id'] = $this->getAuditOwnerUserId();
return $data;
}
} }

View File

@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalType;
use App\Enums\Weekday;
use App\Models\Concerns\HasUuids;
use App\Service\Dto\GoalFiltersDto;
use Database\Factories\GoalFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property string $id
* @property string $name
* @property GoalType $type
* @property GoalComparison $comparison
* @property int $target_seconds
* @property GoalPeriod $period
* @property GoalFiltersDto $filters
* @property string $timezone
* @property Weekday $week_start
* @property Carbon|null $archived_at
* @property-read bool $is_archived
* @property string $organization_id
* @property string|null $member_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Organization $organization
* @property-read Member|null $member
*
* @method static GoalFactory factory()
*/
class Goal extends Model
{
/** @use HasFactory<GoalFactory> */
use HasFactory;
use HasUuids;
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'type' => GoalType::class,
'comparison' => GoalComparison::class,
'target_seconds' => 'int',
'period' => GoalPeriod::class,
'filters' => GoalFiltersDto::class,
'week_start' => Weekday::class,
'archived_at' => 'datetime',
];
/**
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
/**
* The member whose time entries count towards the goal, null for goals that count every member.
*
* @return BelongsTo<Member, $this>
*/
public function member(): BelongsTo
{
return $this->belongsTo(Member::class, 'member_id');
}
public function isPersonalGoalOf(Member $member): bool
{
return $this->type === GoalType::Personal && $this->member_id === $member->getKey();
}
/**
* @return Attribute<bool, never>
*/
protected function isArchived(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),
);
}
/**
* @param Builder<Goal> $builder
* @return Builder<Goal>
*/
public function scopeArchived(Builder $builder): Builder
{
return $builder->whereNotNull('archived_at');
}
/**
* @param Builder<Goal> $builder
* @return Builder<Goal>
*/
public function scopeNotArchived(Builder $builder): Builder
{
return $builder->whereNull('archived_at');
}
}

View File

@@ -28,7 +28,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property-read User $user * @property-read User $user
* @property-read Collection<int, ProjectMember> $projectMembers * @property-read Collection<int, ProjectMember> $projectMembers
* @property-read Collection<int, TimeEntry> $timeEntries * @property-read Collection<int, TimeEntry> $timeEntries
* @property-read Collection<int, Goal> $goals
* *
* @method static MemberFactory factory() * @method static MemberFactory factory()
*/ */
@@ -79,14 +78,4 @@ class Member extends Pivot implements AuditableContract
{ {
return $this->hasMany(ProjectMember::class, 'member_id'); return $this->hasMany(ProjectMember::class, 'member_id');
} }
/**
* Goals whose progress counts the time entries of this member.
*
* @return HasMany<Goal, $this>
*/
public function goals(): HasMany
{
return $this->hasMany(Goal::class, 'member_id');
}
} }

View File

@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -96,20 +97,10 @@ class Organization extends Model implements AuditableContract
protected $attributes = [ protected $attributes = [
]; ];
public function getAuditOwnerOrganizationId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/** /**
* Get all the users that belong to the team. * Get all the users that belong to the team.
* *
* @return BelongsToMany<User, $this, Member, 'membership'> * @return BelongsToMany<User, $this, Pivot, 'membership'>
*/ */
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
@@ -142,7 +133,7 @@ class Organization extends Model implements AuditableContract
} }
/** /**
* @return BelongsToMany<User, $this, Member, 'membership'> * @return BelongsToMany<User, $this, Pivot, 'membership'>
*/ */
public function realUsers(): BelongsToMany public function realUsers(): BelongsToMany
{ {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\AuditableThroughParent;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectMemberFactory; use Database\Factories\ProjectMemberFactory;
@@ -30,7 +29,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization) * @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
* @method static ProjectMemberFactory factory() * @method static ProjectMemberFactory factory()
*/ */
class ProjectMember extends Model implements AuditableContract, AuditableThroughParent class ProjectMember extends Model implements AuditableContract
{ {
use CustomAuditable; use CustomAuditable;
@@ -83,12 +82,4 @@ class ProjectMember extends Model implements AuditableContract, AuditableThrough
$query->whereBelongsTo($organization, 'organization'); $query->whereBelongsTo($organization, 'organization');
}); });
} }
/**
* @return BelongsTo<Project, $this>
*/
public function getAuditParentRelation(): BelongsTo
{
return $this->project();
}
} }

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