mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-09 14:23:19 +01:00
Compare commits
1 Commits
feature/os
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2fa45b107 |
185
.github/workflows/playwright-extensions.yml
vendored
185
.github/workflows/playwright-extensions.yml
vendored
@@ -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.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Build environment for Anthropic's OSS Scanner (https://github.com/anthropics/oss-scanner).
|
||||
# The scanner builds this image with the repository root as the build context and then audits it without network
|
||||
# access, so everything needed to run the app and its test suite (PHP + Composer deps, Node deps + built frontend,
|
||||
# a local PostgreSQL) is installed here.
|
||||
#
|
||||
# Inside the finished image:
|
||||
# .oss-scanner/start-postgres.sh start the local PostgreSQL server (required for tests and the app)
|
||||
# php artisan test run the PHPUnit suite
|
||||
# php artisan serve run the app on http://127.0.0.1:8000 (after `php artisan migrate --seed`)
|
||||
|
||||
FROM node:20-bookworm-slim AS node
|
||||
|
||||
FROM php:8.3-cli-bookworm
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||
COMPOSER_NO_INTERACTION=1 \
|
||||
TZ=UTC
|
||||
|
||||
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
# PostgreSQL 15 (Debian bookworm default) is the database solidtime runs and is tested against.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git unzip curl ca-certificates postgresql postgresql-client \
|
||||
&& install-php-extensions pdo_pgsql pgsql intl gd zip bcmath exif pcntl sockets soap \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& echo "memory_limit=2G" > "$PHP_INI_DIR/conf.d/99-oss-scanner.ini"
|
||||
|
||||
# Database matching .env.ci: user root / password root, database laravel.
|
||||
RUN pg_ctlcluster 15 main start \
|
||||
&& runuser -u postgres -- psql -c "CREATE ROLE root WITH LOGIN SUPERUSER PASSWORD 'root';" \
|
||||
&& runuser -u postgres -- createdb -O root laravel \
|
||||
&& pg_ctlcluster 15 main stop
|
||||
|
||||
# scanner contract: the checkout lives inside the image, at /src
|
||||
COPY . /src
|
||||
WORKDIR /src
|
||||
|
||||
RUN composer install --prefer-dist \
|
||||
&& npm ci \
|
||||
&& npm run build
|
||||
|
||||
RUN cp .env.ci .env \
|
||||
&& php artisan key:generate \
|
||||
&& php artisan passport:keys --force \
|
||||
&& chmod +x .oss-scanner/start-postgres.sh
|
||||
|
||||
# Run the test suite so the image is known to work, but do not fail the build on test failures.
|
||||
RUN .oss-scanner/start-postgres.sh \
|
||||
&& (php artisan test || echo "WARNING: PHPUnit reported failures") \
|
||||
&& pg_ctlcluster 15 main stop
|
||||
@@ -1,18 +0,0 @@
|
||||
# Used instead of the root .dockerignore when building .oss-scanner/Dockerfile. The root one is tailored to the
|
||||
# production image and excludes tests, phpunit.xml etc., which the scanner needs.
|
||||
node_modules
|
||||
extensions/*/node_modules
|
||||
vendor
|
||||
.env
|
||||
public/build
|
||||
public/hot
|
||||
storage/*.key
|
||||
storage/logs/*
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
.phpunit.cache
|
||||
.phpunit.result.cache
|
||||
auth.json
|
||||
.DS_Store
|
||||
.idea
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the local PostgreSQL server used by the test suite and the app (see .oss-scanner/Dockerfile).
|
||||
set -euo pipefail
|
||||
|
||||
pg_ctlcluster 15 main start 2>/dev/null || true
|
||||
until pg_isready -h 127.0.0.1 -p 5432 -q; do sleep 0.5; done
|
||||
echo "PostgreSQL is running on 127.0.0.1:5432 (user root, password root, database laravel)"
|
||||
@@ -1,95 +0,0 @@
|
||||
# Threat model
|
||||
|
||||
## What this project does
|
||||
|
||||
solidtime is an open-source, multi-tenant time tracking web application (Laravel backend, Vue 3 + Inertia frontend,
|
||||
PostgreSQL). It runs as a hosted SaaS (solidtime.io) and is self-hosted by many organisations. Users belong to one or
|
||||
more **organizations**; inside an organization each member has a role: `owner`, `admin`, `manager`, `employee` or
|
||||
`placeholder` (an imported, non-login member). What each role may do is defined in `app/Service/PermissionStore.php`.
|
||||
|
||||
The most important security property is **isolation**: a user must never read or modify data of an organization they
|
||||
are not a member of, and within an organization a member must not exceed the permissions of their role (e.g. an
|
||||
employee must not see other members' time entries, billable rates, or manage members, unless the organization settings
|
||||
explicitly allow it).
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
- **Super admins are fully trusted.** They are the instance operators, configured via the `SUPER_ADMINS` env
|
||||
variable, and have access to the Filament admin panel (`app/Filament`), which can view and change data of every
|
||||
organization and impersonate users. Anything a super admin can do through the panel (including XSS, SQL injection,
|
||||
SSRF or file access that is only reachable from the panel) is not a vulnerability.
|
||||
- What **is** in scope: a user who is not a super admin reaching the admin panel, or any of its actions, at all.
|
||||
- Operators of a self-hosted instance (shell, database, environment, filesystem access) are trusted.
|
||||
- Everyone else, including organization owners and admins when acting outside their own organization, is untrusted.
|
||||
|
||||
## Where untrusted input enters
|
||||
|
||||
All authenticated users, including employees of any organization and anyone who self-registers (registration is open
|
||||
by default), are untrusted.
|
||||
|
||||
- **JSON API** `routes/api.php` (`/api/v1/...`), authenticated via Passport (session cookie or personal access token).
|
||||
Most routes are scoped by `{organization}` and authorised in the controllers / form requests.
|
||||
- **Public, unauthenticated** endpoints: `GET /api/v1/public/reports` (shared reports, accessed by a secret), login,
|
||||
registration, password reset, email verification, organization invitation acceptance (`routes/web.php`).
|
||||
- **Web / Inertia routes** `routes/web.php` and Fortify/Jetstream actions in `app/Actions`.
|
||||
- **Imports** (`app/Service/Import/Importers`): user-uploaded CSV and ZIP files from Toggl, Clockify, Harvest,
|
||||
generic CSV and solidtime's own export format. ZIP handling is in `ZipImportHelper.php`.
|
||||
- **Exports / reports** (`app/Service/Export`, `app/Service/ReportExport`): CSV/XLSX/ODS and PDF. PDFs are rendered by
|
||||
sending HTML to a Gotenberg (headless Chromium) service, so user-controlled content in that HTML matters.
|
||||
- **OAuth** (Passport) authorization and token endpoints.
|
||||
- **Filament admin panel** (`app/Filament`): only its access control is in scope (see Trust boundaries).
|
||||
|
||||
## Components that matter most / least
|
||||
|
||||
Most important: organization scoping and role checks in the API controllers, form requests (`app/Http/Requests`),
|
||||
`PermissionStore`, public report sharing, invitations and member management (role changes, ownership transfer, member
|
||||
merge), authentication flows (Fortify, 2FA, email change, API tokens), import parsing.
|
||||
|
||||
Less important / out of scope:
|
||||
- `extensions/` is empty in this repository (proprietary modules are not part of the open-source code).
|
||||
- `docker/`, `k8s/`, `e2e/`, `playwright/`, `docs/` and developer tooling.
|
||||
- Third-party dependencies in `vendor/` and `node_modules/`, unless solidtime uses them in an unsafe way.
|
||||
|
||||
## How to exercise it
|
||||
|
||||
- `.oss-scanner/start-postgres.sh` starts the local PostgreSQL server (user `root`, password `root`, db `laravel`).
|
||||
- `php artisan test` runs the PHPUnit suite. Endpoint tests in `tests/Unit/Endpoint/Api/V1/` show how to create users,
|
||||
organizations and members with factories and call the API with a given role; they are the quickest way to write a
|
||||
reproducer. Example: `php artisan test --filter=TimeEntryEndpointTest`.
|
||||
- To run the app: `php artisan migrate:fresh --seed && php artisan serve` (http://127.0.0.1:8000). Note that the
|
||||
test suite and the app share the same database.
|
||||
- There is no network: Gotenberg (PDF generation) and mail delivery are not available. Mail uses the `array`
|
||||
driver in tests. The 8 PDF export tests in `TimeEntryEndpointTest` fail for this reason; that is expected.
|
||||
|
||||
## How we rate severity
|
||||
|
||||
- **Critical**: unauthenticated access to other users' data or accounts; authentication bypass; remote code execution;
|
||||
SQL injection reachable by any registered user; reading or writing data of an organization the attacker is not a
|
||||
member of.
|
||||
- **High**: privilege escalation within an organization (e.g. employee to admin/owner, or performing admin-only
|
||||
actions); access to data the role must not see (other members' time entries, billable rates, member emails) when
|
||||
the organization settings do not allow it; stored XSS that executes in another user's session; SSRF via PDF
|
||||
rendering or imports; account takeover requiring user interaction.
|
||||
- **Medium**: information disclosure with limited impact, CSRF on state-changing endpoints, issues requiring an
|
||||
unusual but realistic configuration, denial of service by a single authenticated request (e.g. pathological
|
||||
import file).
|
||||
- **Low**: everything else with real security impact.
|
||||
|
||||
## Anything to leave alone
|
||||
|
||||
Please do not report (see also `SECURITY.md`):
|
||||
- Theoretical findings without a working reproducer.
|
||||
- Missing or weak security headers in isolation; TLS / mail DNS configuration.
|
||||
- Self-XSS; CSRF on non-state-changing endpoints (logout, theme).
|
||||
- CSV / spreadsheet formula injection in exports.
|
||||
- Owners or admins acting destructively within their own organization.
|
||||
- Anything requiring direct DB, shell or filesystem access on a self-hosted instance.
|
||||
- Anything that requires being a super admin, including issues inside the Filament admin panel.
|
||||
- Missing OAuth scope enforcement (not implemented yet).
|
||||
- Rate-limit tuning and generic DoS through volume of requests.
|
||||
|
||||
## Reports and patches
|
||||
|
||||
Please include the affected endpoint or code path, the attacker's role and the victim, a PHPUnit test (in the style of
|
||||
`tests/Unit/Endpoint/Api/V1/`) that reproduces the issue, and a minimal patch that follows the existing patterns
|
||||
(authorisation in form requests/controllers via `PermissionStore`).
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 [
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ class ClientController extends Controller
|
||||
/**
|
||||
* Get clients
|
||||
*
|
||||
* @return ClientCollection<ClientResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getClients
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
|
||||
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
|
||||
use App\Http\Resources\V1\Invitation\InvitationCollection;
|
||||
use App\Http\Resources\V1\Invitation\InvitationResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Service\InvitationService;
|
||||
@@ -29,6 +30,8 @@ class InvitationController extends Controller
|
||||
/**
|
||||
* List all invitations of an organization
|
||||
*
|
||||
* @return InvitationCollection<InvitationResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getInvitations
|
||||
@@ -85,8 +88,6 @@ class InvitationController extends Controller
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @operationId removeInvitation
|
||||
|
||||
@@ -48,6 +48,8 @@ class MemberController extends Controller
|
||||
/**
|
||||
* List all members of an organization
|
||||
*
|
||||
* @return MemberCollection<MemberResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getMembers
|
||||
@@ -143,9 +145,6 @@ class MemberController extends Controller
|
||||
/**
|
||||
* 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 OnlyPlaceholdersCanBeMergedIntoAnotherMember
|
||||
* @throws Throwable
|
||||
|
||||
@@ -35,6 +35,8 @@ class ProjectController extends Controller
|
||||
/**
|
||||
* Get projects visible to the current user
|
||||
*
|
||||
* @return ProjectCollection<ProjectResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getProjects
|
||||
|
||||
@@ -36,6 +36,8 @@ class ProjectMemberController extends Controller
|
||||
/**
|
||||
* Get project members for project
|
||||
*
|
||||
* @return ProjectMemberCollection<ProjectMemberResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getProjectMembers
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Http\Requests\V1\Report\ReportStoreRequest;
|
||||
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
||||
use App\Http\Resources\V1\Report\DetailedReportResource;
|
||||
use App\Http\Resources\V1\Report\ReportCollection;
|
||||
use App\Http\Resources\V1\Report\ReportResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Report;
|
||||
use App\Service\Dto\ReportPropertiesDto;
|
||||
@@ -34,6 +35,8 @@ class ReportController extends Controller
|
||||
/**
|
||||
* Get reports
|
||||
*
|
||||
* @return ReportCollection<ReportResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getReports
|
||||
@@ -68,8 +71,6 @@ class ReportController extends Controller
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @operationId createReport
|
||||
|
||||
@@ -29,6 +29,8 @@ class TagController extends Controller
|
||||
/**
|
||||
* Get tags
|
||||
*
|
||||
* @return TagCollection<TagResource>
|
||||
*
|
||||
* @operationId getTags
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
|
||||
@@ -51,6 +51,8 @@ class TaskController extends Controller
|
||||
/**
|
||||
* Get tasks
|
||||
*
|
||||
* @return TaskCollection<TaskResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getTasks
|
||||
|
||||
@@ -109,13 +109,10 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
* If you only need time entries for a specific user, you can filter by `member_id`.
|
||||
* 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.
|
||||
* 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`).
|
||||
* @return TimeEntryCollection<TimeEntryResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
@@ -354,11 +351,6 @@ class TimeEntryController extends Controller
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* @return array{
|
||||
@@ -592,12 +584,6 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* 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 TimeEntryStillRunningApiException
|
||||
*
|
||||
@@ -648,8 +634,6 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @operationId updateTimeEntry
|
||||
@@ -718,10 +702,6 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
|
||||
@@ -12,7 +12,7 @@ class TimeZoneController extends Controller
|
||||
/**
|
||||
* Get all timezones
|
||||
*
|
||||
* @response array{key: string}[]
|
||||
* @response object{key: string}[]
|
||||
*
|
||||
* @operationId getTimezones
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Service\Dto\UserAgentDto;
|
||||
use App\Service\TimezoneService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -92,6 +94,8 @@ class UserProfileController extends Controller
|
||||
$this->validateTwoFactorAuthenticationState($request);
|
||||
|
||||
return Inertia::render('Profile/Show', [
|
||||
'timezones' => app(TimezoneService::class)->getSelectOptions(),
|
||||
'weekdays' => Weekday::toSelectArray(),
|
||||
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
||||
'sessions' => $this->sessions($request),
|
||||
]);
|
||||
|
||||
@@ -42,7 +42,6 @@ class HandleInertiaRequests extends Middleware
|
||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||
$hasGoals = Module::has('Goals') && Module::isEnabled('Goals');
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
@@ -53,13 +52,11 @@ class HandleInertiaRequests extends Middleware
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'has_invoicing_extension' => $hasInvoicing,
|
||||
'has_services_extension' => $hasServices,
|
||||
'has_goals_extension' => $hasGoals,
|
||||
'billing' => $currentOrganization !== null ? [
|
||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||
'goal_limit' => $billing->getGoalLimit($currentOrganization),
|
||||
] : null,
|
||||
'flash' => [
|
||||
'message' => fn () => $request->session()->get('message'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ class MemberUpdateRequest extends BaseFormRequest
|
||||
'string',
|
||||
Rule::enum(Role::class),
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge(
|
||||
[
|
||||
'nullable',
|
||||
|
||||
@@ -36,7 +36,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
||||
'string',
|
||||
new CurrencyRule,
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge(
|
||||
[
|
||||
'nullable',
|
||||
|
||||
@@ -55,7 +55,6 @@ class ProjectStoreRequest extends BaseFormRequest
|
||||
'required',
|
||||
'boolean',
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge(
|
||||
[
|
||||
'nullable',
|
||||
|
||||
@@ -68,7 +68,6 @@ class ProjectUpdateRequest extends BaseFormRequest
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge([
|
||||
'nullable',
|
||||
],
|
||||
|
||||
@@ -31,7 +31,6 @@ class ProjectMemberStoreRequest extends BaseFormRequest
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge(
|
||||
[
|
||||
'nullable',
|
||||
|
||||
@@ -21,7 +21,6 @@ class ProjectMemberUpdateRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge(
|
||||
[
|
||||
'nullable',
|
||||
|
||||
@@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
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' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
@@ -155,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
'string',
|
||||
Rule::enum(TimeEntryType::class),
|
||||
],
|
||||
// Limit the number of returned time entries (default: 100)
|
||||
// Limit the number of returned time entries (default: 150)
|
||||
'limit' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
|
||||
@@ -32,7 +32,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
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' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -86,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
'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' => [
|
||||
'required',
|
||||
'boolean',
|
||||
|
||||
@@ -43,10 +43,9 @@ class UserUpdateRequest extends BaseFormRequest
|
||||
'email' => [
|
||||
'email:rfc,strict',
|
||||
'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 */
|
||||
return $query->where('is_placeholder', '=', false)
|
||||
->whereKeyNot($this->user->getKey());
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
}),
|
||||
],
|
||||
'photo' => [
|
||||
|
||||
7
app/Http/Resources/PaginatedResourceCollection.php
Normal file
7
app/Http/Resources/PaginatedResourceCollection.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
interface PaginatedResourceCollection {}
|
||||
@@ -21,15 +21,15 @@ class ApiTokenResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
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,
|
||||
/** @var string $name Name of the API token */
|
||||
'name' => $this->resource->name,
|
||||
/** Whether the API token is revoked */
|
||||
/** @var bool $revoked Whether the API token is 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,
|
||||
/** 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),
|
||||
/** @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),
|
||||
|
||||
@@ -29,15 +29,15 @@ class ApiTokenWithAccessTokenResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
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,
|
||||
/** @var string $name Name of the API token */
|
||||
'name' => $this->resource->name,
|
||||
/** Whether the API token is revoked */
|
||||
/** @var bool $revoked Whether the API token is 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,
|
||||
/** 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),
|
||||
/** @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),
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Client;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ClientCollection extends ResourceCollection
|
||||
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,15 +21,15 @@ class ClientResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID */
|
||||
/** @var string $id ID */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name 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,
|
||||
/** When the tag was created */
|
||||
/** @var string $created_at When the tag was created */
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Invitation;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class InvitationCollection extends ResourceCollection
|
||||
class InvitationCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,11 +21,11 @@ class InvitationResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of the invitation */
|
||||
/** @var string $id ID of the invitation */
|
||||
'id' => $this->resource->id,
|
||||
/** Email */
|
||||
/** @var string $email Email */
|
||||
'email' => $this->resource->email,
|
||||
/** Role */
|
||||
/** @var string $role Role */
|
||||
'role' => $this->resource->role,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Member;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class MemberCollection extends ResourceCollection
|
||||
class MemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -22,19 +22,19 @@ class MemberResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of membership */
|
||||
/** @var string $id ID of membership */
|
||||
'id' => $this->resource->id,
|
||||
/** ID of user */
|
||||
/** @var string $id ID of user */
|
||||
'user_id' => $this->resource->user->id,
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->user->name,
|
||||
/** Email */
|
||||
/** @var string $email Email */
|
||||
'email' => $this->resource->user->email,
|
||||
/** Role */
|
||||
/** @var string $role 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,
|
||||
/** Billable rate in cents per hour */
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Member;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class PersonalMembershipCollection extends ResourceCollection
|
||||
class PersonalMembershipCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,17 +21,17 @@ class PersonalMembershipResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of membership */
|
||||
/** @var string $id ID of membership */
|
||||
'id' => $this->resource->id,
|
||||
'organization' => [
|
||||
/** ID of organization */
|
||||
/** @var string $id ID of organization */
|
||||
'id' => $this->resource->organization->id,
|
||||
/** Name of organization */
|
||||
/** @var string $name Name of organization */
|
||||
'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,
|
||||
],
|
||||
/** Role */
|
||||
/** @var string $role Role */
|
||||
'role' => $this->resource->role,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -43,23 +43,23 @@ class OrganizationResource extends BaseResource
|
||||
$currencyService = app(CurrencyService::class);
|
||||
|
||||
return [
|
||||
/** ID */
|
||||
/** @var string $id ID */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name 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,
|
||||
/** 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,
|
||||
/** 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,
|
||||
/** 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,
|
||||
/** 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,
|
||||
/** 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,
|
||||
/** Currency code (ISO 4217) */
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->currency,
|
||||
/** @var string $currency_symbol Currency symbol */
|
||||
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),
|
||||
|
||||
@@ -4,11 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Project;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ProjectCollection extends ResourceCollection
|
||||
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
private bool $showBillableRates;
|
||||
|
||||
|
||||
@@ -30,25 +30,25 @@ class ProjectResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of project */
|
||||
/** @var string $id ID of project */
|
||||
'id' => $this->resource->id,
|
||||
/** Name of project */
|
||||
/** @var string $name Name of project */
|
||||
'name' => $this->resource->name,
|
||||
/** Color of project */
|
||||
/** @var string $color Color of project */
|
||||
'color' => $this->resource->color,
|
||||
/** @var string|null $client_id ID of client */
|
||||
'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,
|
||||
/** 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,
|
||||
/** Project time entries billable default */
|
||||
/** @var bool $is_billable Project time entries billable default */
|
||||
'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,
|
||||
/** 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,
|
||||
/** Whether the project is public */
|
||||
/** @var bool $is_public Whether the project is public */
|
||||
'is_public' => $this->resource->is_public,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ProjectMember;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ProjectMemberCollection extends ResourceCollection
|
||||
class ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,13 +21,13 @@ class ProjectMemberResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of project member */
|
||||
/** @var string $id ID of project member */
|
||||
'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,
|
||||
/** ID of the organization member */
|
||||
/** @var string $member_id ID of the organization member */
|
||||
'member_id' => $this->resource->member_id,
|
||||
/** ID of the project */
|
||||
/** @var string $project_id ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -21,36 +21,36 @@ class DetailedReportResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of the report */
|
||||
/** @var string $id ID of the report */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** Description */
|
||||
/** @var string|null $email 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,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'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 */
|
||||
'shareable_link' => $this->resource->getShareableLink(),
|
||||
'properties' => [
|
||||
/** Type of first grouping */
|
||||
/** @var string $group Type of first grouping */
|
||||
'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,
|
||||
/** 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,
|
||||
/** Start date of the report */
|
||||
/** @var string $start Start date of the report */
|
||||
'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),
|
||||
/** Whether the report is active */
|
||||
/** @var bool|null $active Whether the report is active */
|
||||
'active' => $this->resource->properties->active,
|
||||
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
|
||||
'member_ids' => $this->resource->properties->memberIds?->toArray(),
|
||||
/** Filter by billable status */
|
||||
/** @var bool|null $billable Filter by billable status */
|
||||
'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,
|
||||
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
|
||||
'client_ids' => $this->resource->properties->clientIds?->toArray(),
|
||||
@@ -58,18 +58,18 @@ class DetailedReportResource extends BaseResource
|
||||
'project_ids' => $this->resource->properties->projectIds?->toArray(),
|
||||
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
|
||||
'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,
|
||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||
'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 minutes for time entries */
|
||||
/** @var int|null $rounding_minutes Rounding minutes for time entries */
|
||||
'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),
|
||||
/** 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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -73,13 +73,13 @@ class DetailedWithDataReportResource extends BaseResource
|
||||
$currencyService = app(CurrencyService::class);
|
||||
|
||||
return [
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** Description */
|
||||
/** @var string|null $email Description */
|
||||
'description' => $this->resource->description,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||
/** Currency code (ISO 4217) */
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->organization->currency,
|
||||
/** @var NumberFormat $number_format Number format */
|
||||
'number_format' => $this->resource->organization->number_format->value,
|
||||
@@ -94,15 +94,15 @@ class DetailedWithDataReportResource extends BaseResource
|
||||
/** @var TimeFormat $time_format Time format */
|
||||
'time_format' => $this->resource->organization->time_format->value,
|
||||
'properties' => [
|
||||
/** Type of first grouping */
|
||||
/** @var string $group Type of first grouping */
|
||||
'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,
|
||||
/** 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,
|
||||
/** Start date of the report */
|
||||
/** @var string $start Start date of the report */
|
||||
'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),
|
||||
],
|
||||
/** @var array{
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Report;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ReportCollection extends ResourceCollection
|
||||
class ReportCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,21 +21,21 @@ class ReportResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of the report */
|
||||
/** @var string $id ID of the report */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** Description */
|
||||
/** @var string|null $email 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,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'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 */
|
||||
'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),
|
||||
/** 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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Tag;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class TagCollection extends ResourceCollection
|
||||
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,13 +21,13 @@ class TagResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID */
|
||||
/** @var string $id ID */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name 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),
|
||||
/** When the tag was last updated */
|
||||
/** @var string $updated_at When the tag was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Task;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class TaskCollection extends ResourceCollection
|
||||
class TaskCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -22,21 +22,21 @@ class TaskResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID */
|
||||
/** @var string $id ID */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name 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,
|
||||
/** ID of the project */
|
||||
/** @var string $project_id ID of the project */
|
||||
'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,
|
||||
/** 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,
|
||||
/** When the tag was created */
|
||||
/** @var string $created_at When the tag was created */
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\TimeEntry;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class TimeEntryCollection extends ResourceCollection
|
||||
class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,10 +21,10 @@ class TimeEntryResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of time entry */
|
||||
/** @var string $id ID of time entry */
|
||||
'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),
|
||||
/**
|
||||
@@ -35,19 +35,19 @@ class TimeEntryResource extends BaseResource
|
||||
'duration' => (int) $this->resource->getDuration()?->totalSeconds,
|
||||
/** @var string|null $description Description of time entry */
|
||||
'description' => $this->resource->description,
|
||||
/** ID of task */
|
||||
/** @var string|null $task_id ID of task */
|
||||
'task_id' => $this->resource->task_id,
|
||||
/** ID of project */
|
||||
/** @var string|null $project_id ID of project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
/** ID of organization */
|
||||
/** @var string $organization_id ID of organization */
|
||||
'organization_id' => $this->resource->organization_id,
|
||||
/** ID of user */
|
||||
/** @var string $user_id ID of user */
|
||||
'user_id' => $this->resource->user_id,
|
||||
/** List of tag IDs */
|
||||
/** @var array<string> $tags List of tag IDs */
|
||||
'tags' => $this->resource->tags ?? [],
|
||||
/** Whether time entry is billable */
|
||||
/** @var bool $billable Whether time entry is 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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -22,21 +22,21 @@ class UserResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of user */
|
||||
/** @var string $id ID of user */
|
||||
'id' => $this->resource->id,
|
||||
/** Name of user */
|
||||
/** @var string $name Name of user */
|
||||
'name' => $this->resource->name,
|
||||
/** Email of user */
|
||||
/** @var string $email Email of user */
|
||||
'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,
|
||||
/** Profile photo URL */
|
||||
/** @var string $profile_photo_url 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,
|
||||
/** @var Weekday $week_start Starting day of the week */
|
||||
'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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,19 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AuditableWithoutOwner;
|
||||
use Database\Factories\AuditFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Carbon;
|
||||
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string|null $actor_type
|
||||
* @property string|null $actor_id
|
||||
* @property string|null $user_type
|
||||
* @property string|null $user_id
|
||||
* @property string $event
|
||||
* @property string $auditable_type
|
||||
* @property string $auditable_id
|
||||
@@ -26,71 +22,13 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
||||
* @property string|null $ip_address
|
||||
* @property string|null $user_agent
|
||||
* @property string|null $tags
|
||||
* @property string|null $owner_user_id
|
||||
* @property string|null $owner_organization_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read User|null $ownerUser
|
||||
* @property-read Organization|null $ownerOrganization
|
||||
*
|
||||
* @method static AuditFactory factory()
|
||||
* @method static Builder<Audit> whereMissingOwner()
|
||||
*/
|
||||
class Audit extends PackageAuditModel
|
||||
{
|
||||
/** @use HasFactory<AuditFactory> */
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
|
||||
trait CustomAuditable
|
||||
@@ -21,92 +19,4 @@ trait CustomAuditable
|
||||
{
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property-read User $user
|
||||
* @property-read Collection<int, ProjectMember> $projectMembers
|
||||
* @property-read Collection<int, TimeEntry> $timeEntries
|
||||
* @property-read Collection<int, Goal> $goals
|
||||
*
|
||||
* @method static MemberFactory factory()
|
||||
*/
|
||||
@@ -79,14 +78,4 @@ class Member extends Pivot implements AuditableContract
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,16 +96,6 @@ class Organization extends Model implements AuditableContract
|
||||
protected $attributes = [
|
||||
];
|
||||
|
||||
public function getAuditOwnerOrganizationId(): ?string
|
||||
{
|
||||
return $this->getKey();
|
||||
}
|
||||
|
||||
protected function isAuditOwnerOfItself(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the users that belong to the team.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AuditableThroughParent;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ProjectMemberFactory;
|
||||
@@ -30,7 +29,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
|
||||
* @method static ProjectMemberFactory factory()
|
||||
*/
|
||||
class ProjectMember extends Model implements AuditableContract, AuditableThroughParent
|
||||
class ProjectMember extends Model implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
@@ -83,12 +82,4 @@ class ProjectMember extends Model implements AuditableContract, AuditableThrough
|
||||
$query->whereBelongsTo($organization, 'organization');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function getAuditParentRelation(): BelongsTo
|
||||
{
|
||||
return $this->project();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,16 +124,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
'send_time_entry_still_running_email' => true,
|
||||
];
|
||||
|
||||
public function getAuditOwnerUserId(): ?string
|
||||
{
|
||||
return $this->getKey();
|
||||
}
|
||||
|
||||
protected function isAuditOwnerOfItself(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to the user's profile photo.
|
||||
*
|
||||
|
||||
@@ -17,7 +17,6 @@ use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\GoalsContract;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\IpLookup\NoIpLookupService;
|
||||
use App\Service\PermissionStore;
|
||||
@@ -104,7 +103,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Extensions
|
||||
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
||||
$this->app->bind(BillingContract::class);
|
||||
$this->app->bind(GoalsContract::class);
|
||||
|
||||
// Storage
|
||||
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
|
||||
|
||||
@@ -43,15 +43,6 @@ class BillingContract
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum number of goals the organization can have; null means unlimited.
|
||||
* Archived goals count toward the limit.
|
||||
*/
|
||||
public function getGoalLimit(Organization $organization): ?int
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the organization is blocked
|
||||
* A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial
|
||||
|
||||
@@ -8,11 +8,9 @@ use App\Enums\Role;
|
||||
use App\Events\BeforeOrganizationDeletion;
|
||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
use App\Models\Client;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\Passport\RefreshToken;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Report;
|
||||
@@ -77,9 +75,6 @@ class DeletionService
|
||||
// Delete all reports
|
||||
Report::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||
|
||||
// Delete all goals
|
||||
Goal::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||
|
||||
// Reset the current organization
|
||||
$organization->owner()
|
||||
->where('current_team_id', $organization->getKey())
|
||||
@@ -174,10 +169,6 @@ class DeletionService
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh tokens are not linked to the user directly, so they need to be deleted via their access tokens.
|
||||
// Otherwise a still-valid refresh token could be used to issue a new access token for a deleted user,
|
||||
// which fails with a foreign key violation on oauth_access_tokens.user_id.
|
||||
RefreshToken::query()->whereIn('access_token_id', $user->accessTokens()->pluck('id'))->delete();
|
||||
$user->accessTokens()->delete();
|
||||
$user->authCodes()->delete();
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Dto;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Persisted filter set of a goal. Defines which time entries count towards the goal.
|
||||
* A null value means "no restriction" for that dimension. The ID setters store an empty array as null,
|
||||
* so "no restriction" has a single representation.
|
||||
*/
|
||||
class GoalFiltersDto implements Castable
|
||||
{
|
||||
/**
|
||||
* Members whose time entries count. Only used by goals that count every member, null means every member.
|
||||
*
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
public ?Collection $memberIds = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
public ?Collection $projectIds = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
public ?Collection $taskIds = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
public ?Collection $tagIds = null;
|
||||
|
||||
public ?TagMatchType $tagMatchType = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
public ?Collection $clientIds = null;
|
||||
|
||||
public ?bool $billable = null;
|
||||
|
||||
public ?TimeEntryType $timeEntryType = null;
|
||||
|
||||
/**
|
||||
* Get the caster class to use when casting from / to this cast target.
|
||||
*
|
||||
* @param array<string, mixed> $arguments
|
||||
* @return CastsAttributes<GoalFiltersDto, GoalFiltersDto>
|
||||
*/
|
||||
public static function castUsing(array $arguments): CastsAttributes
|
||||
{
|
||||
return new class implements CastsAttributes
|
||||
{
|
||||
private const array REQUIRED_PROPERTIES = [
|
||||
'memberIds',
|
||||
'projectIds',
|
||||
'taskIds',
|
||||
'tagIds',
|
||||
'tagMatchType',
|
||||
'clientIds',
|
||||
'billable',
|
||||
'timeEntryType',
|
||||
];
|
||||
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): GoalFiltersDto
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
throw new \InvalidArgumentException('The given value is not a string');
|
||||
}
|
||||
$data = json_decode($value, false);
|
||||
if (! is_object($data)) {
|
||||
throw new \InvalidArgumentException('The given value is not a JSON object string');
|
||||
}
|
||||
foreach (self::REQUIRED_PROPERTIES as $property) {
|
||||
if (! property_exists($data, $property)) {
|
||||
throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"');
|
||||
}
|
||||
}
|
||||
$dto = new GoalFiltersDto;
|
||||
$dto->setMemberIds(isset($data->memberIds) ? (array) $data->memberIds : null);
|
||||
$dto->setProjectIds(isset($data->projectIds) ? (array) $data->projectIds : null);
|
||||
$dto->setTaskIds(isset($data->taskIds) ? (array) $data->taskIds : null);
|
||||
$dto->setTagIds(isset($data->tagIds) ? (array) $data->tagIds : null);
|
||||
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
|
||||
$dto->setClientIds(isset($data->clientIds) ? (array) $data->clientIds : null);
|
||||
$dto->billable = isset($data->billable) ? (bool) $data->billable : null;
|
||||
$dto->timeEntryType = isset($data->timeEntryType) ? TimeEntryType::from($data->timeEntryType) : null;
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): string
|
||||
{
|
||||
if (! ($value instanceof GoalFiltersDto)) {
|
||||
throw new \InvalidArgumentException('The given value is not an instance of GoalFiltersDto');
|
||||
}
|
||||
|
||||
$data = (object) [
|
||||
'memberIds' => $value->memberIds?->toArray(),
|
||||
'projectIds' => $value->projectIds?->toArray(),
|
||||
'taskIds' => $value->taskIds?->toArray(),
|
||||
'tagIds' => $value->tagIds?->toArray(),
|
||||
'tagMatchType' => $value->tagMatchType?->value,
|
||||
'clientIds' => $value->clientIds?->toArray(),
|
||||
'billable' => $value->billable,
|
||||
'timeEntryType' => $value->timeEntryType?->value,
|
||||
];
|
||||
|
||||
$jsonString = json_encode($data);
|
||||
if ($jsonString === false) {
|
||||
throw new \InvalidArgumentException('Could not encode the given data to a JSON string');
|
||||
}
|
||||
|
||||
return $jsonString;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $memberIds
|
||||
*/
|
||||
public function setMemberIds(?array $memberIds): void
|
||||
{
|
||||
$this->memberIds = $memberIds !== null && count($memberIds) > 0 ? TimeEntryFilter::idArrayToCollection($memberIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $projectIds
|
||||
*/
|
||||
public function setProjectIds(?array $projectIds): void
|
||||
{
|
||||
$this->projectIds = $projectIds !== null && count($projectIds) > 0 ? TimeEntryFilter::idArrayToCollection($projectIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $taskIds
|
||||
*/
|
||||
public function setTaskIds(?array $taskIds): void
|
||||
{
|
||||
$this->taskIds = $taskIds !== null && count($taskIds) > 0 ? TimeEntryFilter::idArrayToCollection($taskIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $tagIds
|
||||
*/
|
||||
public function setTagIds(?array $tagIds): void
|
||||
{
|
||||
$this->tagIds = $tagIds !== null && count($tagIds) > 0 ? TimeEntryFilter::idArrayToCollection($tagIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $clientIds
|
||||
*/
|
||||
public function setClientIds(?array $clientIds): void
|
||||
{
|
||||
$this->clientIds = $clientIds !== null && count($clientIds) > 0 ? TimeEntryFilter::idArrayToCollection($clientIds) : null;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Dto;
|
||||
|
||||
use App\Enums\GoalStatus;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
readonly class GoalProgressDto
|
||||
{
|
||||
/**
|
||||
* @param Carbon $periodStart Start of the period in UTC (inclusive)
|
||||
* @param Carbon $periodEnd End of the period in UTC (exclusive)
|
||||
*/
|
||||
public function __construct(
|
||||
public Carbon $periodStart,
|
||||
public Carbon $periodEnd,
|
||||
public int $trackedSeconds,
|
||||
public GoalStatus $status,
|
||||
) {}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class ReportPropertiesDto implements Castable
|
||||
@@ -116,13 +117,13 @@ class ReportPropertiesDto implements Castable
|
||||
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
|
||||
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
|
||||
$dto->active = $data->active;
|
||||
$dto->memberIds = $data->memberIds !== null ? TimeEntryFilter::idArrayToCollection($data->memberIds) : null;
|
||||
$dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null;
|
||||
$dto->billable = $data->billable;
|
||||
$dto->clientIds = $data->clientIds !== null ? TimeEntryFilter::idArrayToCollection($data->clientIds) : null;
|
||||
$dto->projectIds = $data->projectIds !== null ? TimeEntryFilter::idArrayToCollection($data->projectIds) : null;
|
||||
$dto->tagIds = $data->tagIds !== null ? TimeEntryFilter::idArrayToCollection($data->tagIds) : null;
|
||||
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
|
||||
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
|
||||
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
|
||||
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
|
||||
$dto->taskIds = $data->taskIds ? TimeEntryFilter::idArrayToCollection($data->taskIds) : null;
|
||||
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
|
||||
$dto->group = TimeEntryAggregationType::from($data->group);
|
||||
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
||||
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
||||
@@ -179,12 +180,32 @@ class ReportPropertiesDto implements Castable
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $ids
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
public static function idArrayToCollection(array $ids): Collection
|
||||
{
|
||||
$collection = new Collection;
|
||||
foreach ($ids as $id) {
|
||||
if (! is_string($id)) {
|
||||
throw new InvalidArgumentException('The given ID is not a string');
|
||||
}
|
||||
if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) {
|
||||
throw new InvalidArgumentException('The given ID is not a valid UUID');
|
||||
}
|
||||
$collection->push($id);
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $memberIds
|
||||
*/
|
||||
public function setMemberIds(?array $memberIds): void
|
||||
{
|
||||
$this->memberIds = $memberIds !== null ? TimeEntryFilter::idArrayToCollection($memberIds) : null;
|
||||
$this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,7 +213,7 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public function setClientIds(?array $clientIds): void
|
||||
{
|
||||
$this->clientIds = $clientIds !== null ? TimeEntryFilter::idArrayToCollection($clientIds) : null;
|
||||
$this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +221,7 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public function setProjectIds(?array $projectIds): void
|
||||
{
|
||||
$this->projectIds = $projectIds !== null ? TimeEntryFilter::idArrayToCollection($projectIds) : null;
|
||||
$this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,7 +229,7 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public function setTagIds(?array $tagIds): void
|
||||
{
|
||||
$this->tagIds = $tagIds !== null ? TimeEntryFilter::idArrayToCollection($tagIds) : null;
|
||||
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
|
||||
}
|
||||
|
||||
public function setTagMatchType(?TagMatchType $tagMatchType): void
|
||||
@@ -221,6 +242,6 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public function setTaskIds(?array $taskIds): void
|
||||
{
|
||||
$this->taskIds = $taskIds !== null ? TimeEntryFilter::idArrayToCollection($taskIds) : null;
|
||||
$this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\GoalComparison;
|
||||
use App\Enums\GoalPeriod;
|
||||
use App\Enums\GoalStatus;
|
||||
use App\Models\Goal;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\Dto\GoalProgressDto;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class GoalProgressService
|
||||
{
|
||||
/**
|
||||
* Progress of the goal in the period that contains $now.
|
||||
*/
|
||||
public function getCurrentProgress(Goal $goal, Carbon $now): GoalProgressDto
|
||||
{
|
||||
[$periodStart, $periodEnd] = $this->getPeriodBounds($goal, $now);
|
||||
$trackedSeconds = $this->getProgress($goal, $periodStart, $periodEnd);
|
||||
|
||||
return new GoalProgressDto($periodStart, $periodEnd, $trackedSeconds, $this->getStatus($goal, $trackedSeconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress of the goals in the period that contains $now, keyed by goal ID.
|
||||
* Runs one query per goal, since every goal has its own filters, timezone and period.
|
||||
*
|
||||
* @param Collection<int, Goal> $goals
|
||||
* @return array<string, GoalProgressDto>
|
||||
*/
|
||||
public function getCurrentProgressForGoals(Collection $goals, Carbon $now): array
|
||||
{
|
||||
$progress = [];
|
||||
foreach ($goals as $goal) {
|
||||
$progress[$goal->getKey()] = $this->getCurrentProgress($goal, $now);
|
||||
}
|
||||
|
||||
return $progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds of the period that contains $date, in UTC. The period is calculated in the timezone of the goal
|
||||
* and respects the week start of the goal. The start is inclusive, the end exclusive.
|
||||
*
|
||||
* @return array{0: Carbon, 1: Carbon}
|
||||
*/
|
||||
public function getPeriodBounds(Goal $goal, Carbon $date): array
|
||||
{
|
||||
$dateInTimezone = $date->copy()->setTimezone($goal->timezone);
|
||||
|
||||
$start = match ($goal->period) {
|
||||
GoalPeriod::Day => $dateInTimezone->copy()->startOfDay(),
|
||||
GoalPeriod::Week => $dateInTimezone->copy()->startOfWeek($goal->week_start->carbonWeekDay()),
|
||||
GoalPeriod::Month => $dateInTimezone->copy()->startOfMonth(),
|
||||
};
|
||||
$end = match ($goal->period) {
|
||||
GoalPeriod::Day => $start->copy()->addDay(),
|
||||
GoalPeriod::Week => $start->copy()->addWeek(),
|
||||
GoalPeriod::Month => $start->copy()->addMonth(),
|
||||
};
|
||||
|
||||
return [$start->utc(), $end->utc()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds tracked in the given period that count towards the goal.
|
||||
* Counts the time entries of the member of the goal, or of every member for goals without a member.
|
||||
* Time entries are assigned to the period their start is in, like in the reporting.
|
||||
* A running time entry counts with its elapsed time up to now.
|
||||
*
|
||||
* @param Carbon $periodStart Start of the period (inclusive)
|
||||
* @param Carbon $periodEnd End of the period (exclusive)
|
||||
*/
|
||||
private function getProgress(Goal $goal, Carbon $periodStart, Carbon $periodEnd): int
|
||||
{
|
||||
$filters = $goal->filters;
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->where('organization_id', '=', $goal->organization_id);
|
||||
if ($goal->member_id !== null) {
|
||||
$query->where('member_id', '=', $goal->member_id);
|
||||
}
|
||||
$timeEntryFilter = new TimeEntryFilter($query);
|
||||
$timeEntryFilter
|
||||
->addStart($periodStart)
|
||||
->addEnd($periodEnd)
|
||||
->addProjectIdsFilter($filters->projectIds?->toArray())
|
||||
->addTaskIdsFilter($filters->taskIds?->toArray())
|
||||
->addTagIdsFilter($filters->tagIds?->toArray(), $filters->tagMatchType)
|
||||
->addClientIdsFilter($filters->clientIds?->toArray())
|
||||
->addBillable($filters->billable)
|
||||
->addType($filters->timeEntryType);
|
||||
if ($goal->member_id === null) {
|
||||
$timeEntryFilter->addMemberIdsFilter($filters->memberIds?->toArray());
|
||||
}
|
||||
|
||||
/** @var object{tracked_seconds: int|float|string|null} $row */
|
||||
$row = $timeEntryFilter->get()
|
||||
->selectRaw('coalesce(round(sum(extract(epoch from (coalesce("end", ?::timestamp) - "start")))), 0) as tracked_seconds', [Carbon::now()])
|
||||
->toBase()
|
||||
->first();
|
||||
|
||||
return max(0, (int) $row->tracked_seconds);
|
||||
}
|
||||
|
||||
public function getStatus(Goal $goal, int $trackedSeconds): GoalStatus
|
||||
{
|
||||
return match ($goal->comparison) {
|
||||
GoalComparison::AtLeast => $trackedSeconds >= $goal->target_seconds
|
||||
? GoalStatus::Achieved
|
||||
: GoalStatus::InProgress,
|
||||
GoalComparison::LessThan => $trackedSeconds < $goal->target_seconds
|
||||
? GoalStatus::OnTrack
|
||||
: GoalStatus::Exceeded,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\GoalType;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Member;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Decides which goals a member can reach. The `goals:*:own` permissions gate the feature; this decides the rows.
|
||||
* Core only has personal goals, reachable by their member alone (not even admins or owners).
|
||||
* The team goals extension rebinds this to add organization goals.
|
||||
*/
|
||||
class GoalsContract
|
||||
{
|
||||
/**
|
||||
* Whether $actor is allowed to create a goal of the given type for the given member.
|
||||
*
|
||||
* @param string|null $targetMemberId Member whose time entries count, null for every member (organization goals only)
|
||||
*/
|
||||
public function canCreateGoal(Member $actor, GoalType $type, ?string $targetMemberId): bool
|
||||
{
|
||||
return $type === GoalType::Personal && $targetMemberId === $actor->getKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $actor is allowed to see the goal (including its progress).
|
||||
*/
|
||||
public function canViewGoal(Member $actor, Goal $goal): bool
|
||||
{
|
||||
return $goal->isPersonalGoalOf($actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $actor is allowed to update the goal (including its filters and archiving it).
|
||||
*/
|
||||
public function canUpdateGoal(Member $actor, Goal $goal): bool
|
||||
{
|
||||
return $goal->isPersonalGoalOf($actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $actor is allowed to delete the goal.
|
||||
*/
|
||||
public function canDeleteGoal(Member $actor, Goal $goal): bool
|
||||
{
|
||||
return $goal->isPersonalGoalOf($actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the query to the goals that $actor is allowed to see.
|
||||
*
|
||||
* @param Builder<Goal> $query
|
||||
* @return Builder<Goal>
|
||||
*/
|
||||
public function scopeVisibleGoals(Builder $query, Member $actor): Builder
|
||||
{
|
||||
return $query->where('type', '=', GoalType::Personal->value)
|
||||
->where('member_id', '=', $actor->getKey());
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
@@ -110,12 +109,6 @@ class MemberService
|
||||
}
|
||||
}
|
||||
|
||||
// A goal is deleted together with the member it is for, it is not part of the "still in use" check
|
||||
Goal::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->where('member_id', '=', $member->getKey())
|
||||
->delete();
|
||||
|
||||
$member->delete();
|
||||
|
||||
if ($isPlaceholder) {
|
||||
@@ -188,30 +181,6 @@ class MemberService
|
||||
->whereBelongsToOrganization($organization)
|
||||
->whereBelongsTo($fromMember, 'member')
|
||||
->delete();
|
||||
|
||||
// Goals: the goal belongs to the person it is for, like time entries it moves along
|
||||
Goal::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->where('member_id', '=', $fromMember->getKey())
|
||||
->update([
|
||||
'member_id' => $toMember->getKey(),
|
||||
]);
|
||||
|
||||
// Goal member filters: the time entries of $fromMember now belong to $toMember, so the filters follow them
|
||||
$goalsWithMemberFilter = Goal::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereJsonContains('filters->memberIds', $fromMember->getKey())
|
||||
->get();
|
||||
foreach ($goalsWithMemberFilter as $goal) {
|
||||
$filters = $goal->filters;
|
||||
$memberIds = $filters->memberIds?->map(
|
||||
fn (string $memberId): string => $memberId === $fromMember->getKey() ? $toMember->getKey() : $memberId
|
||||
);
|
||||
// $toMember may already be in the filter
|
||||
$filters->setMemberIds($memberIds?->unique()->values()->all());
|
||||
$goal->filters = $filters;
|
||||
$goal->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,14 +75,6 @@ class PermissionStore
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'goals:view:own',
|
||||
'goals:create:own',
|
||||
'goals:update:own',
|
||||
'goals:delete:own',
|
||||
'goals:view:organization-type',
|
||||
'goals:create:organization-type',
|
||||
'goals:update:organization-type',
|
||||
'goals:delete:organization-type',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
@@ -154,14 +146,6 @@ class PermissionStore
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'goals:view:own',
|
||||
'goals:create:own',
|
||||
'goals:update:own',
|
||||
'goals:delete:own',
|
||||
'goals:view:organization-type',
|
||||
'goals:create:organization-type',
|
||||
'goals:update:organization-type',
|
||||
'goals:delete:organization-type',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
@@ -222,14 +206,6 @@ class PermissionStore
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'goals:view:own',
|
||||
'goals:create:own',
|
||||
'goals:update:own',
|
||||
'goals:delete:own',
|
||||
'goals:view:organization-type',
|
||||
'goals:create:organization-type',
|
||||
'goals:update:organization-type',
|
||||
'goals:delete:organization-type',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
@@ -257,10 +233,6 @@ class PermissionStore
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'organizations:view',
|
||||
'goals:view:own',
|
||||
'goals:create:own',
|
||||
'goals:update:own',
|
||||
'goals:delete:own',
|
||||
],
|
||||
'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.',
|
||||
],
|
||||
|
||||
@@ -10,9 +10,7 @@ use App\Models\Member;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TimeEntryFilter
|
||||
{
|
||||
@@ -31,26 +29,6 @@ class TimeEntryFilter
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $ids
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
public static function idArrayToCollection(array $ids): Collection
|
||||
{
|
||||
$collection = new Collection;
|
||||
foreach ($ids as $id) {
|
||||
if (! is_string($id)) {
|
||||
throw new \InvalidArgumentException('The given ID is not a string');
|
||||
}
|
||||
if ($id !== self::NONE_VALUE && ! Str::isUuid($id)) {
|
||||
throw new \InvalidArgumentException('The given ID is not a valid UUID');
|
||||
}
|
||||
$collection->push($id);
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
public function addEndFilter(?string $dateTime): self
|
||||
{
|
||||
if ($dateTime === null) {
|
||||
|
||||
79
composer.lock
generated
79
composer.lock
generated
@@ -429,24 +429,23 @@
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "1.0.0",
|
||||
"version": "0.19.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/brick/math.git",
|
||||
"reference": "2effe05d2177c451b86c6a073196a4034c02f211"
|
||||
"reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211",
|
||||
"reference": "2effe05d2177c451b86c6a073196a4034c02f211",
|
||||
"url": "https://api.github.com/repos/brick/math/zipball/a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce",
|
||||
"reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "2.2.13",
|
||||
"phpstan/phpstan-phpunit": "2.0.18",
|
||||
"phpstan/phpstan": "2.1.22",
|
||||
"phpunit/phpunit": "^11.5"
|
||||
},
|
||||
"type": "library",
|
||||
@@ -477,7 +476,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/brick/math/issues",
|
||||
"source": "https://github.com/brick/math/tree/1.0.0"
|
||||
"source": "https://github.com/brick/math/tree/0.19.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -485,7 +484,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-12T10:28:18+00:00"
|
||||
"time": "2026-08-08T23:03:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/money",
|
||||
@@ -4219,16 +4218,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/fortify",
|
||||
"version": "v1.40.0",
|
||||
"version": "v1.39.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/fortify.git",
|
||||
"reference": "fe0fce8814660317df0684f2c7be3b573def67d3"
|
||||
"reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/fortify/zipball/fe0fce8814660317df0684f2c7be3b573def67d3",
|
||||
"reference": "fe0fce8814660317df0684f2c7be3b573def67d3",
|
||||
"url": "https://api.github.com/repos/laravel/fortify/zipball/b1fc50707bbe007fd92165d8b7d460ab549b355a",
|
||||
"reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4279,24 +4278,24 @@
|
||||
"issues": "https://github.com/laravel/fortify/issues",
|
||||
"source": "https://github.com/laravel/fortify"
|
||||
},
|
||||
"time": "2026-09-10T11:52:08+00:00"
|
||||
"time": "2026-08-23T07:46:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v13.33.0",
|
||||
"version": "v13.32.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359"
|
||||
"reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/91188a17ceaa3dbace6e8a5f7abd0d042e466359",
|
||||
"reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/cdd8b33c246719acdd118c705ce8c7ab5ef48a96",
|
||||
"reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
|
||||
"composer-runtime-api": "^2.2",
|
||||
"doctrine/inflector": "^2.0.5",
|
||||
"dragonmantank/cron-expression": "^3.4",
|
||||
@@ -4510,20 +4509,20 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2026-09-22T14:12:33+00:00"
|
||||
"time": "2026-09-15T14:55:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/octane",
|
||||
"version": "v2.20.0",
|
||||
"version": "v2.19.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/octane.git",
|
||||
"reference": "df4c38dcddc5f51e36e7f1d788841130c4fb8f45"
|
||||
"reference": "68a2516a0318baba0de0e4648f61e335c5e69dca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/octane/zipball/df4c38dcddc5f51e36e7f1d788841130c4fb8f45",
|
||||
"reference": "df4c38dcddc5f51e36e7f1d788841130c4fb8f45",
|
||||
"url": "https://api.github.com/repos/laravel/octane/zipball/68a2516a0318baba0de0e4648f61e335c5e69dca",
|
||||
"reference": "68a2516a0318baba0de0e4648f61e335c5e69dca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4599,7 +4598,7 @@
|
||||
"issues": "https://github.com/laravel/octane/issues",
|
||||
"source": "https://github.com/laravel/octane"
|
||||
},
|
||||
"time": "2026-08-23T17:25:20+00:00"
|
||||
"time": "2026-08-13T13:58:52+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/passkeys",
|
||||
@@ -4805,16 +4804,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v2.1.0",
|
||||
"version": "v2.0.16",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/serializable-closure.git",
|
||||
"reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5"
|
||||
"reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5",
|
||||
"reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5",
|
||||
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
|
||||
"reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4862,7 +4861,7 @@
|
||||
"issues": "https://github.com/laravel/serializable-closure/issues",
|
||||
"source": "https://github.com/laravel/serializable-closure"
|
||||
},
|
||||
"time": "2026-09-22T14:32:34+00:00"
|
||||
"time": "2026-08-18T20:28:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/tinker",
|
||||
@@ -15338,16 +15337,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/sail",
|
||||
"version": "v1.68.0",
|
||||
"version": "v1.67.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/sail.git",
|
||||
"reference": "2bc304083d515065b03944e425e62cb3c526c33e"
|
||||
"reference": "639e03ac12cf23def171770bcab05758045b2642"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/sail/zipball/2bc304083d515065b03944e425e62cb3c526c33e",
|
||||
"reference": "2bc304083d515065b03944e425e62cb3c526c33e",
|
||||
"url": "https://api.github.com/repos/laravel/sail/zipball/639e03ac12cf23def171770bcab05758045b2642",
|
||||
"reference": "639e03ac12cf23def171770bcab05758045b2642",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -15397,7 +15396,7 @@
|
||||
"issues": "https://github.com/laravel/sail/issues",
|
||||
"source": "https://github.com/laravel/sail"
|
||||
},
|
||||
"time": "2026-09-18T14:34:46+00:00"
|
||||
"time": "2026-08-12T13:55:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sentinel",
|
||||
@@ -15457,16 +15456,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/telescope",
|
||||
"version": "v5.25.0",
|
||||
"version": "v5.24.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/telescope.git",
|
||||
"reference": "65fe6724f8688de01dfa74b28a0edc191bc3caa8"
|
||||
"reference": "50cbcf4553ddfd8d4bd456b04f97dfed923007d9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/telescope/zipball/65fe6724f8688de01dfa74b28a0edc191bc3caa8",
|
||||
"reference": "65fe6724f8688de01dfa74b28a0edc191bc3caa8",
|
||||
"url": "https://api.github.com/repos/laravel/telescope/zipball/50cbcf4553ddfd8d4bd456b04f97dfed923007d9",
|
||||
"reference": "50cbcf4553ddfd8d4bd456b04f97dfed923007d9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -15519,9 +15518,9 @@
|
||||
"monitoring"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/laravel/telescope/tree/v5.25.0"
|
||||
"source": "https://github.com/laravel/telescope/tree/v5.24.0"
|
||||
},
|
||||
"time": "2026-09-09T14:16:19+00:00"
|
||||
"time": "2026-09-08T16:15:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mockery/mockery",
|
||||
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
*/
|
||||
|
||||
'user' => [
|
||||
'morph_prefix' => 'actor',
|
||||
'morph_prefix' => 'user',
|
||||
'guards' => [
|
||||
'web',
|
||||
'api',
|
||||
|
||||
@@ -35,7 +35,7 @@ return [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
@@ -47,7 +47,7 @@ return [
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
@@ -57,12 +57,12 @@ return [
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSL_MODE', 'prefer'),
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'pgsql_test' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_TEST_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_TEST_PORT', '5432'),
|
||||
'database' => env('DB_TEST_DATABASE', 'forge'),
|
||||
@@ -72,12 +72,12 @@ return [
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSL_MODE', 'prefer'),
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Extensions\Scramble\ApiExceptionTypeToSchema;
|
||||
use App\Extensions\Scramble\PaginatedResourceCollectionTypeToSchema;
|
||||
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
|
||||
|
||||
return [
|
||||
@@ -27,30 +28,7 @@ return [
|
||||
/*
|
||||
* Description rendered on the home page of the API documentation (`/docs/api`).
|
||||
*/
|
||||
'description' => <<<'MD'
|
||||
## Getting started
|
||||
|
||||
All organization endpoints live under `/v1/organizations/{organization}`, where `{organization}` is the organization's ID. Authenticate with `Authorization: Bearer <token>` and send `Accept: application/json`.
|
||||
|
||||
**1. Find yourself.** Call `GET /v1/users/me/memberships`. Each membership contains the **organization ID** (use it as `{organization}` in paths) and your **member ID** in that organization (the membership `id`). Most endpoints filter by member ID, not by user ID.
|
||||
|
||||
**2. Scope to your own data.** For owners and admins, `GET /time-entries` and `GET /time-entries/aggregate` return the whole organization's time entries unless you pass `member_id`. When acting for "me", always pass your member ID, both when reading and before changing entries.
|
||||
|
||||
**3. Resolve names to IDs.** Look up projects, clients, tags, tasks and members by name with their list endpoints (`GET /projects`, `GET /clients`, `GET /tags`, `GET /tasks`, `GET /members`). Never guess IDs.
|
||||
|
||||
**4. Use UTC.** All timestamps are sent and returned in UTC as `Y-m-d\TH:i:s\Z` (example: `2026-10-02T07:30:00Z`). Convert the user's local times and day boundaries to UTC before sending them.
|
||||
|
||||
**5. Money is in cents.** Billable rates and costs are integers in cents of the organization's currency (`8000` means 80.00).
|
||||
|
||||
## Common tasks
|
||||
|
||||
- **Start a timer:** stop the running entry first (find it with `GET /v1/users/me/time-entries/active`, then `PUT` its `end`), then `POST /time-entries` with `start` and `end: null`. Only one entry can run per member.
|
||||
- **Log past work:** `POST /time-entries` once per block with `member_id`, `start`, `end`, `project_id` and `billable` set to the project's `is_billable` (it is not derived automatically).
|
||||
- **Fix or stop an entry:** `PUT /time-entries/{timeEntry}` with the new `start` or `end` in UTC.
|
||||
- **Move entries to another project:** list them with `member_id` and filters, then `PATCH /time-entries` with their `ids` and `changes.project_id`, plus `changes.task_id` set to a task of the new project or `null`.
|
||||
- **Totals and reports:** `GET /time-entries/aggregate` with `group` (for example `client` or `project`) and `start`/`end`; durations are in `seconds`, amounts in `cost` (cents).
|
||||
- **Share a report:** `POST /reports` with `is_public: true` and use the returned `shareable_link`.
|
||||
MD,
|
||||
'description' => '',
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -100,5 +78,6 @@ MD,
|
||||
|
||||
'extensions' => [
|
||||
ApiExceptionTypeToSchema::class,
|
||||
PaginatedResourceCollectionTypeToSchema::class,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\GoalComparison;
|
||||
use App\Enums\GoalPeriod;
|
||||
use App\Enums\GoalType;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Service\Dto\GoalFiltersDto;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Goal>
|
||||
*/
|
||||
class GoalFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$filters = new GoalFiltersDto;
|
||||
$filters->timeEntryType = TimeEntryType::Work;
|
||||
|
||||
return [
|
||||
'name' => $this->faker->sentence(3),
|
||||
'type' => GoalType::Personal,
|
||||
'comparison' => $this->faker->randomElement(GoalComparison::cases()),
|
||||
'target_seconds' => $this->faker->numberBetween(1, 40) * 3600,
|
||||
'period' => $this->faker->randomElement(GoalPeriod::cases()),
|
||||
'filters' => $filters,
|
||||
'timezone' => 'UTC',
|
||||
'week_start' => Weekday::Monday,
|
||||
'archived_at' => null,
|
||||
'organization_id' => Organization::factory(),
|
||||
// The member has to belong to the organization of the goal
|
||||
'member_id' => fn (array $attributes) => Member::factory()->state([
|
||||
'organization_id' => $attributes['organization_id'],
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Goal in the given organization, for a new member of that organization unless forMember() is used.
|
||||
*/
|
||||
public function forOrganization(Organization $organization): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'organization_id' => $organization->getKey(),
|
||||
'member_id' => Member::factory()->forOrganization($organization),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Personal goal of the given member.
|
||||
*/
|
||||
public function forMember(Member $member): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'type' => GoalType::Personal,
|
||||
'member_id' => $member->getKey(),
|
||||
'organization_id' => $member->organization_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization goal that counts the time of the given member.
|
||||
*/
|
||||
public function organizationGoalForMember(Member $member): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'type' => GoalType::Organization,
|
||||
'member_id' => $member->getKey(),
|
||||
'organization_id' => $member->organization_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization goal that counts the time of every member.
|
||||
*/
|
||||
public function forEveryMember(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'type' => GoalType::Organization,
|
||||
'member_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function type(GoalType $type): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'type' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
public function archived(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'archived_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function timezone(string $timezone): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'timezone' => $timezone,
|
||||
]);
|
||||
}
|
||||
|
||||
public function weekStart(Weekday $weekStart): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'week_start' => $weekStart,
|
||||
]);
|
||||
}
|
||||
|
||||
public function atLeast(int $targetSeconds): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'comparison' => GoalComparison::AtLeast,
|
||||
'target_seconds' => $targetSeconds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function lessThan(int $targetSeconds): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'comparison' => GoalComparison::LessThan,
|
||||
'target_seconds' => $targetSeconds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function period(GoalPeriod $period): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'period' => $period,
|
||||
]);
|
||||
}
|
||||
|
||||
public function filters(GoalFiltersDto $filters): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function randomCreatedAt(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ class UserFactory extends Factory
|
||||
|
||||
public function withProfilePicture(): static
|
||||
{
|
||||
$profilePhoto = $this->generateProfilePhoto();
|
||||
$profilePhoto = $this->faker->image(null, 500, 500);
|
||||
/** @see FileHelpers::hashName */
|
||||
$path = 'profile-photos/'.Str::random(40).'.png';
|
||||
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto);
|
||||
@@ -103,21 +103,6 @@ class UserFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a PNG image with a random background color.
|
||||
* Note: The image is generated locally, since downloading a placeholder image (Faker image()) depends on an external service.
|
||||
*/
|
||||
private function generateProfilePhoto(): string
|
||||
{
|
||||
$image = imagecreatetruecolor(500, 500);
|
||||
$color = imagecolorallocate($image, $this->faker->numberBetween(0, 255), $this->faker->numberBetween(0, 255), $this->faker->numberBetween(0, 255));
|
||||
imagefill($image, 0, 0, $color);
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the user should have a personal team.
|
||||
*/
|
||||
|
||||
@@ -18,8 +18,7 @@ class CreateAuditsTable extends Migration
|
||||
|
||||
Schema::connection($connection)->create($table, function (Blueprint $table): void {
|
||||
|
||||
// Note: The morph prefix is hardcoded, since the columns are renamed in a later migration
|
||||
$morphPrefix = 'user';
|
||||
$morphPrefix = config('audit.user.morph_prefix', 'user');
|
||||
|
||||
$table->bigIncrements('id');
|
||||
$table->string($morphPrefix.'_type')->nullable();
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('goals', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name');
|
||||
// personal: a goal a member set for themselves, organization: a goal the organization set (team goals extension)
|
||||
$table->string('type');
|
||||
$table->string('comparison');
|
||||
$table->unsignedBigInteger('target_seconds');
|
||||
$table->string('period');
|
||||
$table->jsonb('filters');
|
||||
// Timezone and week start define the periods of the goal, they are pinned on the goal like on a report
|
||||
$table->string('timezone');
|
||||
$table->string('week_start');
|
||||
$table->dateTime('archived_at')->nullable();
|
||||
$table->uuid('organization_id');
|
||||
$table->foreign('organization_id')
|
||||
->references('id')
|
||||
->on('organizations')
|
||||
->restrictOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
// Member whose time entries count towards the goal, null means every member of the organization
|
||||
// (organization goals only). Personal goals always have a member.
|
||||
$table->uuid('member_id')->nullable();
|
||||
$table->foreign('member_id')
|
||||
->references('id')
|
||||
->on('members')
|
||||
->restrictOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
$table->timestamps();
|
||||
$table->index(['organization_id', 'member_id']);
|
||||
$table->index(['organization_id', 'type']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('goals');
|
||||
}
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Note: Renaming columns and adding nullable columns without default are metadata-only operations in PostgreSQL,
|
||||
* so this migration is fast even for a large audits table.
|
||||
* The indexes and foreign keys for the new columns are added in a separate non-transactional migration.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('audits', function (Blueprint $table): void {
|
||||
$table->renameColumn('user_type', 'actor_type');
|
||||
$table->renameColumn('user_id', 'actor_id');
|
||||
$table->renameIndex('audits_user_id_user_type_index', 'audits_actor_id_actor_type_index');
|
||||
$table->uuid('owner_user_id')->nullable();
|
||||
$table->uuid('owner_organization_id')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('audits', function (Blueprint $table): void {
|
||||
$table->dropColumn('owner_user_id');
|
||||
$table->dropColumn('owner_organization_id');
|
||||
$table->renameIndex('audits_actor_id_actor_type_index', 'audits_user_id_user_type_index');
|
||||
$table->renameColumn('actor_type', 'user_type');
|
||||
$table->renameColumn('actor_id', 'user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* PostgreSQL cannot build an index concurrently inside a transaction.
|
||||
* Keeping this migration non-transactional prevents long write locks on the (large) audits table in production.
|
||||
* Every step is idempotent, so the migration can be re-run if it fails halfway.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $withinTransaction = false;
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$this->createIndex('audits_owner_user_id_index', 'owner_user_id');
|
||||
$this->createIndex('audits_owner_organization_id_index', 'owner_organization_id');
|
||||
|
||||
$this->createForeignKey('audits_owner_user_id_foreign', 'owner_user_id', 'users');
|
||||
$this->createForeignKey('audits_owner_organization_id_foreign', 'owner_organization_id', 'organizations');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE audits DROP CONSTRAINT IF EXISTS audits_owner_user_id_foreign');
|
||||
DB::statement('ALTER TABLE audits DROP CONSTRAINT IF EXISTS audits_owner_organization_id_foreign');
|
||||
DB::statement('DROP INDEX'.$this->concurrently().' IF EXISTS audits_owner_user_id_index');
|
||||
DB::statement('DROP INDEX'.$this->concurrently().' IF EXISTS audits_owner_organization_id_index');
|
||||
}
|
||||
|
||||
private function createIndex(string $index, string $column): void
|
||||
{
|
||||
$state = DB::selectOne(
|
||||
<<<'SQL'
|
||||
SELECT pg_index.indisvalid::int AS valid
|
||||
FROM pg_index
|
||||
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
|
||||
WHERE pg_namespace.nspname = current_schema()
|
||||
AND pg_class.relname = ?
|
||||
SQL,
|
||||
[$index],
|
||||
);
|
||||
|
||||
if ($state !== null && (bool) $state->valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A failed concurrent index build leaves an invalid index behind, that needs to be dropped before rebuilding
|
||||
if ($state !== null) {
|
||||
DB::statement('DROP INDEX'.$this->concurrently().' '.$index);
|
||||
}
|
||||
|
||||
DB::statement('CREATE INDEX'.$this->concurrently().' '.$index.' ON audits ('.$column.')');
|
||||
}
|
||||
|
||||
private function createForeignKey(string $constraint, string $column, string $referencedTable): void
|
||||
{
|
||||
$exists = DB::selectOne(
|
||||
<<<'SQL'
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_constraint.connamespace
|
||||
WHERE pg_namespace.nspname = current_schema()
|
||||
AND pg_constraint.conname = ?
|
||||
SQL,
|
||||
[$constraint],
|
||||
) !== null;
|
||||
|
||||
// Note: Adding the constraint as NOT VALID only needs a short lock, the validation of the existing rows
|
||||
// afterward does not block reads or writes on the audits table.
|
||||
if (! $exists) {
|
||||
DB::statement('ALTER TABLE audits ADD CONSTRAINT '.$constraint.' FOREIGN KEY ('.$column.') REFERENCES '.$referencedTable.' (id) ON DELETE CASCADE NOT VALID');
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE audits VALIDATE CONSTRAINT '.$constraint);
|
||||
}
|
||||
|
||||
private function concurrently(): string
|
||||
{
|
||||
return DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user