mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-09 22:33:18 +01:00
Compare commits
8 Commits
main
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1abe73241 | ||
|
|
0ef944a896 | ||
|
|
2072223f19 | ||
|
|
9ea774c1ac | ||
|
|
d1f7e206f2 | ||
|
|
5f23ce98d7 | ||
|
|
400df23035 | ||
|
|
981bad5414 |
19
.github/workflows/build-onpremise.yml
vendored
19
.github/workflows/build-onpremise.yml
vendored
@@ -103,8 +103,6 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
||||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
||||||
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
|
|
||||||
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
@@ -121,26 +119,9 @@ jobs:
|
|||||||
- name: "Install npm dependencies in invoicing extension"
|
- name: "Install npm dependencies in invoicing extension"
|
||||||
run: cd extensions/Invoicing && npm ci
|
run: cd extensions/Invoicing && npm ci
|
||||||
|
|
||||||
- name: "Checkout auditing extension"
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
|
|
||||||
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
|
|
||||||
path: extensions/Auditing
|
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
|
|
||||||
|
|
||||||
- name: "Install composer dependencies in auditing extension"
|
|
||||||
run: cd extensions/Auditing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
|
||||||
|
|
||||||
- name: "Install npm dependencies in auditing extension"
|
|
||||||
run: cd extensions/Auditing && npm ci
|
|
||||||
|
|
||||||
- name: "Activate invoicing extension"
|
- name: "Activate invoicing extension"
|
||||||
run: php artisan module:enable Invoicing
|
run: php artisan module:enable Invoicing
|
||||||
|
|
||||||
- name: "Activate auditing extension"
|
|
||||||
run: php artisan module:enable Auditing
|
|
||||||
|
|
||||||
- name: "Install npm dependencies"
|
- name: "Install npm dependencies"
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
|||||||
24
.github/workflows/build-private.yml
vendored
24
.github/workflows/build-private.yml
vendored
@@ -84,8 +84,6 @@ jobs:
|
|||||||
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
|
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
|
||||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
||||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
||||||
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
|
|
||||||
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: "Checkout billing extension"
|
- name: "Checkout billing extension"
|
||||||
@@ -147,25 +145,6 @@ jobs:
|
|||||||
- name: "Install npm dependencies in invoicing extension"
|
- name: "Install npm dependencies in invoicing extension"
|
||||||
run: cd extensions/Invoicing && npm ci
|
run: cd extensions/Invoicing && npm ci
|
||||||
|
|
||||||
- name: "Checkout auditing extension"
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
|
|
||||||
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
|
|
||||||
path: extensions/Auditing
|
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
|
|
||||||
|
|
||||||
- name: "Install composer dependencies in auditing extension"
|
|
||||||
uses: php-actions/composer@v6
|
|
||||||
with:
|
|
||||||
working_dir: "extensions/Auditing"
|
|
||||||
command: install
|
|
||||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
|
||||||
php_version: 8.3
|
|
||||||
|
|
||||||
- name: "Install npm dependencies in auditing extension"
|
|
||||||
run: cd extensions/Auditing && npm ci
|
|
||||||
|
|
||||||
- name: "Setup PHP with PECL extension"
|
- name: "Setup PHP with PECL extension"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -189,9 +168,6 @@ jobs:
|
|||||||
- name: "Activate invoicing extension"
|
- name: "Activate invoicing extension"
|
||||||
run: php artisan module:enable Invoicing
|
run: php artisan module:enable Invoicing
|
||||||
|
|
||||||
- name: "Activate auditing extension"
|
|
||||||
run: php artisan module:enable Auditing
|
|
||||||
|
|
||||||
- name: "Install npm dependencies"
|
- name: "Install npm dependencies"
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/npm-format-check.yml
vendored
2
.github/workflows/npm-format-check.yml
vendored
@@ -1,8 +1,6 @@
|
|||||||
name: NPM Format Check
|
name: NPM Format Check
|
||||||
|
|
||||||
on: [push]
|
on: [push]
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format-check:
|
format-check:
|
||||||
|
|||||||
7
.github/workflows/phpunit-extensions.yml
vendored
7
.github/workflows/phpunit-extensions.yml
vendored
@@ -5,7 +5,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
phpunit-extensions:
|
phpunit-extensions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 15
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
postgres_version: [ 15, 16, 17 ]
|
postgres_version: [ 15, 16, 17 ]
|
||||||
@@ -132,8 +132,5 @@ jobs:
|
|||||||
php artisan key:generate
|
php artisan key:generate
|
||||||
php artisan passport:keys
|
php artisan passport:keys
|
||||||
|
|
||||||
- name: "Run PHPUnit (extensions)"
|
- name: "Run PHPUnit"
|
||||||
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
||||||
|
|
||||||
- name: "Run PHPUnit (core)"
|
|
||||||
run: php artisan test --testsuite=Unit,Feature --stop-on-failure
|
|
||||||
|
|||||||
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,86 +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 and a local Gotenberg for PDF rendering) is installed here.
|
|
||||||
#
|
|
||||||
# The base image is the FrankenPHP image the production image (docker/prod/Dockerfile) is built on: in production the
|
|
||||||
# app runs as long-lived Laravel Octane workers in FrankenPHP worker mode, not as one PHP process per request.
|
|
||||||
#
|
|
||||||
# Inside the finished image:
|
|
||||||
# .oss-scanner/start-postgres.sh start the local PostgreSQL server (required for tests and the app)
|
|
||||||
# .oss-scanner/start-gotenberg.sh start the local Gotenberg server (required for PDF exports)
|
|
||||||
# php artisan test run the PHPUnit suite (needs PostgreSQL and Gotenberg running)
|
|
||||||
# .oss-scanner/start-octane.sh run the app like production on http://127.0.0.1:8000 (starts everything above)
|
|
||||||
|
|
||||||
ARG FRANKENPHP_VERSION=1.11
|
|
||||||
ARG PHP_VERSION=8.3
|
|
||||||
|
|
||||||
FROM node:20-trixie-slim AS node
|
|
||||||
|
|
||||||
FROM gotenberg/gotenberg:8 AS gotenberg
|
|
||||||
|
|
||||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
COMPOSER_ALLOW_SUPERUSER=1 \
|
|
||||||
COMPOSER_NO_INTERACTION=1 \
|
|
||||||
OCTANE_SERVER=frankenphp \
|
|
||||||
TZ=UTC
|
|
||||||
|
|
||||||
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 17 (Debian trixie default) is one of the versions solidtime is tested against in CI.
|
|
||||||
# chromium, qpdf and exiftool are what Gotenberg needs for HTML to PDF rendering.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends git unzip curl ca-certificates postgresql postgresql-client \
|
|
||||||
chromium fonts-liberation qpdf libimage-exiftool-perl \
|
|
||||||
&& install-php-extensions pdo_pgsql pgsql intl gd zip bcmath exif pcntl sockets soap apcu \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& echo "memory_limit=2G" > "$PHP_INI_DIR/conf.d/99-oss-scanner.ini"
|
|
||||||
|
|
||||||
# Gotenberg 8 (the PDF renderer, same image as in CI). solidtime only uses its Chromium HTML to PDF route, so LibreOffice
|
|
||||||
# and pdftk are not installed: Gotenberg only checks at startup that their binaries exist, start-gotenberg.sh disables
|
|
||||||
# the LibreOffice routes and the Chromium route does not use pdftk.
|
|
||||||
COPY --from=gotenberg /usr/bin/gotenberg /usr/local/bin/gotenberg
|
|
||||||
COPY --from=gotenberg /usr/bin/pdfcpu /usr/local/bin/pdfcpu
|
|
||||||
COPY --from=gotenberg /opt/gotenberg /opt/gotenberg
|
|
||||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium \
|
|
||||||
CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data \
|
|
||||||
GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions \
|
|
||||||
QPDF_BIN_PATH=/usr/bin/qpdf \
|
|
||||||
EXIFTOOL_BIN_PATH=/usr/bin/exiftool \
|
|
||||||
PDFCPU_BIN_PATH=/usr/local/bin/pdfcpu \
|
|
||||||
LIBREOFFICE_BIN_PATH=/bin/false \
|
|
||||||
UNOCONVERTER_BIN_PATH=/bin/false \
|
|
||||||
PDFTK_BIN_PATH=/bin/false
|
|
||||||
|
|
||||||
# Database matching .env.ci: user root / password root, database laravel.
|
|
||||||
RUN pg_ctlcluster 17 main start \
|
|
||||||
&& runuser -u postgres -- psql -c "CREATE ROLE root WITH LOGIN SUPERUSER PASSWORD 'root';" \
|
|
||||||
&& runuser -u postgres -- createdb -O root laravel \
|
|
||||||
&& pg_ctlcluster 17 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 \
|
|
||||||
&& php artisan octane:install --server=frankenphp --no-interaction \
|
|
||||||
&& chmod +x .oss-scanner/*.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 \
|
|
||||||
&& .oss-scanner/start-gotenberg.sh \
|
|
||||||
&& (php artisan test || echo "WARNING: PHPUnit reported failures") \
|
|
||||||
&& kill "$(cat /tmp/gotenberg.pid)" \
|
|
||||||
&& pg_ctlcluster 17 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,16 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Start the local Gotenberg server (PDF rendering) on 127.0.0.1:3000, matching GOTENBERG_URL in .env.ci.
|
|
||||||
# Only the Chromium routes are needed by solidtime, see .oss-scanner/Dockerfile. Stop: kill "$(cat /tmp/gotenberg.pid)"
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if ! curl -fs http://127.0.0.1:3000/health >/dev/null; then
|
|
||||||
nohup gotenberg \
|
|
||||||
--api-port=3000 \
|
|
||||||
--libreoffice-disable-routes \
|
|
||||||
--libreoffice-auto-start=false \
|
|
||||||
--log-level=warn \
|
|
||||||
>/tmp/gotenberg.log 2>&1 &
|
|
||||||
echo $! >/tmp/gotenberg.pid
|
|
||||||
until curl -fs http://127.0.0.1:3000/health >/dev/null; do sleep 0.5; done
|
|
||||||
fi
|
|
||||||
echo "Gotenberg is running on http://127.0.0.1:3000 (log: /tmp/gotenberg.log)"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Run the app the way production does: Laravel Octane on FrankenPHP in worker mode, with the production Caddyfile.
|
|
||||||
# A worker boots the app once and then serves many requests, so state kept in memory (static properties, singletons,
|
|
||||||
# changed config, ...) survives from one request to the next. With a single worker, consecutive requests always hit the
|
|
||||||
# same worker, so such leaks between requests (e.g. between users of different organizations) reproduce reliably.
|
|
||||||
#
|
|
||||||
# Usage: .oss-scanner/start-octane.sh (OCTANE_WORKERS and OCTANE_MAX_REQUESTS can be overridden)
|
|
||||||
# Stop: php artisan octane:stop
|
|
||||||
# After changing PHP code, run `php artisan octane:reload`: workers keep the code they booted with.
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
.oss-scanner/start-postgres.sh
|
|
||||||
.oss-scanner/start-gotenberg.sh
|
|
||||||
|
|
||||||
php artisan migrate --force
|
|
||||||
if [ "$(PGPASSWORD=root psql -h 127.0.0.1 -U root -d laravel -tAc 'SELECT count(*) FROM users')" = "0" ]; then
|
|
||||||
php artisan db:seed --force
|
|
||||||
fi
|
|
||||||
php artisan optimize:clear >/dev/null
|
|
||||||
|
|
||||||
nohup php artisan octane:frankenphp \
|
|
||||||
--host=127.0.0.1 \
|
|
||||||
--port=8000 \
|
|
||||||
--workers="${OCTANE_WORKERS:-1}" \
|
|
||||||
--max-requests="${OCTANE_MAX_REQUESTS:-10000}" \
|
|
||||||
--caddyfile=docker/prod/deployment/octane/FrankenPHP/Caddyfile \
|
|
||||||
>/tmp/octane.log 2>&1 &
|
|
||||||
|
|
||||||
until curl -fs -o /dev/null http://127.0.0.1:8000/login; do
|
|
||||||
if ! kill -0 $! 2>/dev/null; then
|
|
||||||
cat /tmp/octane.log
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 0.5
|
|
||||||
done
|
|
||||||
echo "solidtime is running on http://127.0.0.1:8000 with Octane/FrankenPHP (log: /tmp/octane.log)"
|
|
||||||
@@ -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 17 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,122 +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.
|
|
||||||
|
|
||||||
## Runtime: long-lived Octane workers
|
|
||||||
|
|
||||||
In production (the hosted SaaS and the official Docker image, `docker/prod/`) the app does **not** run as one PHP
|
|
||||||
process per request. It runs on Laravel Octane with FrankenPHP in worker mode: each worker boots the application once
|
|
||||||
and then serves many requests from different users and organizations. Anything kept in memory survives from one request
|
|
||||||
to the next unless Octane resets it (`config/octane.php` lists what is reset). This includes static properties and
|
|
||||||
static caches, container bindings registered with `singleton()` instead of `scoped()` (see
|
|
||||||
`app/Providers/AppServiceProvider.php`), objects captured by those singletons, runtime `config()` / locale / timezone
|
|
||||||
changes, macros and event listeners registered during a request, and state in third-party packages.
|
|
||||||
|
|
||||||
Request A leaving state behind that request B (another user, possibly of another organization) then sees or is
|
|
||||||
affected by is in scope, rated by its impact like any other issue (see severity below). The PHPUnit suite cannot
|
|
||||||
show this class of bug, because it boots a fresh application for every test. It has to be reproduced over HTTP against
|
|
||||||
the Octane server, see "How to exercise it".
|
|
||||||
|
|
||||||
## 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`).
|
|
||||||
- `.oss-scanner/start-gotenberg.sh` starts the local Gotenberg server (PDF rendering via headless Chromium) on
|
|
||||||
http://127.0.0.1:3000, so the PDF export code path, including what Chromium does with the rendered HTML, can be
|
|
||||||
exercised.
|
|
||||||
- `php artisan test` runs the PHPUnit suite (start PostgreSQL and Gotenberg first); all tests are expected to pass. 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 like production: `.oss-scanner/start-octane.sh` (http://127.0.0.1:8000). It starts PostgreSQL and
|
|
||||||
Gotenberg, migrates (and seeds an empty database, see `database/seeders/DatabaseSeeder.php` for the users) and runs
|
|
||||||
Octane/FrankenPHP with the production Caddyfile and a **single worker**, so consecutive requests always hit the same
|
|
||||||
worker and leaks between requests reproduce reliably. Workers keep the code they booted with: after changing PHP
|
|
||||||
code run `php artisan octane:reload`. Stop it with `php artisan octane:stop`. Note that the test suite and the app
|
|
||||||
share the same database, so `php artisan test` wipes the app's data.
|
|
||||||
- A reproducer for a leak between requests is a script that sends request A (e.g. as a member of organization X) and
|
|
||||||
then request B (as a user of organization Y) to the running Octane server and shows that B observes A's state. API
|
|
||||||
requests can be authenticated with a personal access token, e.g. created with
|
|
||||||
`php artisan tinker --execute="echo App\Models\User::where('email', '...')->first()->createToken('t')->accessToken;"`.
|
|
||||||
- There is no network: mail delivery is not available (mail uses the `array` driver in tests), and remote resources
|
|
||||||
referenced by PDF templates (e.g. fonts from fonts.bunny.net) fail to load, so PDFs fall back to local fonts.
|
|
||||||
|
|
||||||
## 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, including through state leaking between requests in an Octane worker.
|
|
||||||
- **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 (for leaks between requests: a script against
|
|
||||||
`.oss-scanner/start-octane.sh` instead), 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AuditResource\Pages\CreateAudit;
|
||||||
|
use App\Filament\Resources\AuditResource\Pages\ListAudits;
|
||||||
|
use App\Filament\Resources\AuditResource\Pages\ViewAudit;
|
||||||
|
use App\Models\Audit;
|
||||||
|
use Filament\Actions\ViewAction;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||||
|
|
||||||
|
class AuditResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Audit::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'System';
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
TextInput::make('user_type')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('user_id'),
|
||||||
|
TextInput::make('event')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('auditable_type')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('auditable_id')
|
||||||
|
->required(),
|
||||||
|
PrettyJsonField::make('old_values'),
|
||||||
|
PrettyJsonField::make('new_values'),
|
||||||
|
Textarea::make('url'),
|
||||||
|
TextInput::make('ip_address'),
|
||||||
|
TextInput::make('user_agent')
|
||||||
|
->maxLength(1023),
|
||||||
|
TextInput::make('tags')
|
||||||
|
->maxLength(255),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('user.name'),
|
||||||
|
TextColumn::make('event'),
|
||||||
|
TextColumn::make('auditable_type'),
|
||||||
|
TextColumn::make('auditable_id'),
|
||||||
|
IconColumn::make('was_command')
|
||||||
|
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->sortable()
|
||||||
|
->dateTime(),
|
||||||
|
TextColumn::make('updated_at')
|
||||||
|
->sortable()
|
||||||
|
->dateTime(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
ViewAction::make(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
])
|
||||||
|
->defaultSort('created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListAudits::route('/'),
|
||||||
|
'create' => CreateAudit::route('/create'),
|
||||||
|
'view' => ViewAudit::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
13
app/Filament/Resources/AuditResource/Pages/CreateAudit.php
Normal file
13
app/Filament/Resources/AuditResource/Pages/CreateAudit.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AuditResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AuditResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateAudit extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AuditResource::class;
|
||||||
|
}
|
||||||
18
app/Filament/Resources/AuditResource/Pages/ListAudits.php
Normal file
18
app/Filament/Resources/AuditResource/Pages/ListAudits.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AuditResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AuditResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListAudits extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = AuditResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Filament/Resources/AuditResource/Pages/ViewAudit.php
Normal file
13
app/Filament/Resources/AuditResource/Pages/ViewAudit.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AuditResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AuditResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewAudit extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AuditResource::class;
|
||||||
|
}
|
||||||
@@ -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
|
* Get clients
|
||||||
*
|
*
|
||||||
|
* @return ClientCollection<ClientResource>
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId getClients
|
* @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\InvitationIndexRequest;
|
||||||
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
|
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
|
||||||
use App\Http\Resources\V1\Invitation\InvitationCollection;
|
use App\Http\Resources\V1\Invitation\InvitationCollection;
|
||||||
|
use App\Http\Resources\V1\Invitation\InvitationResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
use App\Service\InvitationService;
|
use App\Service\InvitationService;
|
||||||
@@ -29,6 +30,8 @@ class InvitationController extends Controller
|
|||||||
/**
|
/**
|
||||||
* List all invitations of an organization
|
* List all invitations of an organization
|
||||||
*
|
*
|
||||||
|
* @return InvitationCollection<InvitationResource>
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId getInvitations
|
* @operationId getInvitations
|
||||||
@@ -85,8 +88,6 @@ class InvitationController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Remove a pending invitation
|
* Remove a pending invitation
|
||||||
*
|
*
|
||||||
* This revokes the invitation: the link in the invitation email stops working. Find the invitation ID with `GET /organizations/{organization}/invitations`.
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId removeInvitation
|
* @operationId removeInvitation
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ class MemberController extends Controller
|
|||||||
/**
|
/**
|
||||||
* List all members of an organization
|
* List all members of an organization
|
||||||
*
|
*
|
||||||
|
* @return MemberCollection<MemberResource>
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId getMembers
|
* @operationId getMembers
|
||||||
@@ -143,9 +145,6 @@ class MemberController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Merge one member into another
|
* Merge one member into another
|
||||||
*
|
*
|
||||||
* Only placeholder members (for example people created by an import) can be merged. All time entries and other data of the placeholder
|
|
||||||
* are reassigned to the member given in `member_id`, and the placeholder is removed. Find both member IDs with `GET /organizations/{organization}/members`.
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
|
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class ProjectController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get projects visible to the current user
|
* Get projects visible to the current user
|
||||||
*
|
*
|
||||||
|
* @return ProjectCollection<ProjectResource>
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId getProjects
|
* @operationId getProjects
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class ProjectMemberController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get project members for project
|
* Get project members for project
|
||||||
*
|
*
|
||||||
|
* @return ProjectMemberCollection<ProjectMemberResource>
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId getProjectMembers
|
* @operationId getProjectMembers
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Http\Requests\V1\Report\ReportStoreRequest;
|
|||||||
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
||||||
use App\Http\Resources\V1\Report\DetailedReportResource;
|
use App\Http\Resources\V1\Report\DetailedReportResource;
|
||||||
use App\Http\Resources\V1\Report\ReportCollection;
|
use App\Http\Resources\V1\Report\ReportCollection;
|
||||||
|
use App\Http\Resources\V1\Report\ReportResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Report;
|
use App\Models\Report;
|
||||||
use App\Service\Dto\ReportPropertiesDto;
|
use App\Service\Dto\ReportPropertiesDto;
|
||||||
@@ -34,6 +35,8 @@ class ReportController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get reports
|
* Get reports
|
||||||
*
|
*
|
||||||
|
* @return ReportCollection<ReportResource>
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId getReports
|
* @operationId getReports
|
||||||
@@ -68,8 +71,6 @@ class ReportController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Create report
|
* Create report
|
||||||
*
|
*
|
||||||
* A report is a saved set of filters. Set `is_public` to `true` to share it: the response then contains the `shareable_link` that can be opened without logging in.
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId createReport
|
* @operationId createReport
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class TagController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get tags
|
* Get tags
|
||||||
*
|
*
|
||||||
|
* @return TagCollection<TagResource>
|
||||||
|
*
|
||||||
* @operationId getTags
|
* @operationId getTags
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class TaskController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get tasks
|
* Get tasks
|
||||||
*
|
*
|
||||||
|
* @return TaskCollection<TaskResource>
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId getTasks
|
* @operationId getTasks
|
||||||
|
|||||||
@@ -109,13 +109,10 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get time entries in organization
|
* Get time entries in organization
|
||||||
*
|
*
|
||||||
* Without a member filter this returns the time entries of all members of the organization (for users who may view all time entries, such as owners and admins), not only your own.
|
* If you only need time entries for a specific user, you can filter by `member_id`.
|
||||||
* To get only your own time entries, pass your member ID as `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
|
|
||||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
||||||
*
|
*
|
||||||
* The `start` and `end` filters both apply to the start time of an entry, in UTC. Convert the user's local day boundaries to UTC first.
|
* @return TimeEntryCollection<TimeEntryResource>
|
||||||
* Results are paginated with `limit` (default 100, max 500) and `offset`; check `meta.total` and fetch further pages when needed.
|
|
||||||
* To find the running timer, use `active=true` (or `GET /v1/users/me/time-entries/active`).
|
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
@@ -354,11 +351,6 @@ class TimeEntryController extends Controller
|
|||||||
* The parameters `group` and `sub_group` allow you to group the time entries by different criteria.
|
* The parameters `group` and `sub_group` allow you to group the time entries by different criteria.
|
||||||
* If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.
|
* If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.
|
||||||
*
|
*
|
||||||
* Durations are returned in `seconds` (divide by 3600 for hours) and amounts in `cost` as cents in the organization's currency (divide by 100 for money).
|
|
||||||
* Filter by member with `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
|
|
||||||
* Array filters use the query format `client_ids[]=<id>`.
|
|
||||||
* Example: billable hours per client for a period: `group=client&billable=true&start=...&end=...`.
|
|
||||||
*
|
|
||||||
* @operationId getAggregatedTimeEntries
|
* @operationId getAggregatedTimeEntries
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
@@ -592,12 +584,6 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Create time entry
|
* Create time entry
|
||||||
*
|
*
|
||||||
* `billable` is not taken from the project. To match the web app, set it to the project's `is_billable` value (or `false` without a project).
|
|
||||||
*
|
|
||||||
* A member can only have one running time entry (an entry with `end` set to `null`). Creating a running entry while another one runs fails with `time_entry_still_running`.
|
|
||||||
* To start a new timer, first stop the running entry by updating its `end` to the current time, then create the new entry.
|
|
||||||
* To log past work, send both `start` and `end` (UTC). Create one entry per block of work.
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
* @throws TimeEntryStillRunningApiException
|
* @throws TimeEntryStillRunningApiException
|
||||||
*
|
*
|
||||||
@@ -648,8 +634,6 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Update time entry
|
* Update time entry
|
||||||
*
|
*
|
||||||
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
|
|
||||||
*
|
|
||||||
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
|
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
|
||||||
*
|
*
|
||||||
* @operationId updateTimeEntry
|
* @operationId updateTimeEntry
|
||||||
@@ -718,10 +702,6 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Update multiple time entries
|
* Update multiple time entries
|
||||||
*
|
*
|
||||||
* Applies the same `changes` to every entry in `ids`. To find the IDs, list entries with `GET /organizations/{organization}/time-entries`
|
|
||||||
* (filtered by `member_id`, the project and the other criteria), then send their IDs here.
|
|
||||||
* When `changes.project_id` moves entries to another project, also set `changes.task_id` to a task of the new project or to `null`, because tasks belong to a project.
|
|
||||||
*
|
|
||||||
* @operationId updateMultipleTimeEntries
|
* @operationId updateMultipleTimeEntries
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class TimeZoneController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get all timezones
|
* Get all timezones
|
||||||
*
|
*
|
||||||
* @response array{key: string}[]
|
* @response object{key: string}[]
|
||||||
*
|
*
|
||||||
* @operationId getTimezones
|
* @operationId getTimezones
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\Weekday;
|
||||||
use App\Service\Dto\UserAgentDto;
|
use App\Service\Dto\UserAgentDto;
|
||||||
|
use App\Service\TimezoneService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller;
|
use Illuminate\Routing\Controller;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -92,6 +94,8 @@ class UserProfileController extends Controller
|
|||||||
$this->validateTwoFactorAuthenticationState($request);
|
$this->validateTwoFactorAuthenticationState($request);
|
||||||
|
|
||||||
return Inertia::render('Profile/Show', [
|
return Inertia::render('Profile/Show', [
|
||||||
|
'timezones' => app(TimezoneService::class)->getSelectOptions(),
|
||||||
|
'weekdays' => Weekday::toSelectArray(),
|
||||||
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
||||||
'sessions' => $this->sessions($request),
|
'sessions' => $this->sessions($request),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class HandleInertiaRequests extends Middleware
|
|||||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||||
$hasGoals = Module::has('Goals') && Module::isEnabled('Goals');
|
|
||||||
|
|
||||||
/** @var BillingContract $billing */
|
/** @var BillingContract $billing */
|
||||||
$billing = app(BillingContract::class);
|
$billing = app(BillingContract::class);
|
||||||
@@ -53,13 +52,11 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'has_billing_extension' => $hasBilling,
|
'has_billing_extension' => $hasBilling,
|
||||||
'has_invoicing_extension' => $hasInvoicing,
|
'has_invoicing_extension' => $hasInvoicing,
|
||||||
'has_services_extension' => $hasServices,
|
'has_services_extension' => $hasServices,
|
||||||
'has_goals_extension' => $hasGoals,
|
|
||||||
'billing' => $currentOrganization !== null ? [
|
'billing' => $currentOrganization !== null ? [
|
||||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||||
'goal_limit' => $billing->getGoalLimit($currentOrganization),
|
|
||||||
] : null,
|
] : null,
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'message' => fn () => $request->session()->get('message'),
|
'message' => fn () => $request->session()->get('message'),
|
||||||
|
|||||||
@@ -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',
|
'string',
|
||||||
Rule::enum(Role::class),
|
Rule::enum(Role::class),
|
||||||
],
|
],
|
||||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
|
||||||
'billable_rate' => array_merge(
|
'billable_rate' => array_merge(
|
||||||
[
|
[
|
||||||
'nullable',
|
'nullable',
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
new CurrencyRule,
|
new CurrencyRule,
|
||||||
],
|
],
|
||||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
|
||||||
'billable_rate' => array_merge(
|
'billable_rate' => array_merge(
|
||||||
[
|
[
|
||||||
'nullable',
|
'nullable',
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ class ProjectStoreRequest extends BaseFormRequest
|
|||||||
'required',
|
'required',
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
|
||||||
'billable_rate' => array_merge(
|
'billable_rate' => array_merge(
|
||||||
[
|
[
|
||||||
'nullable',
|
'nullable',
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ class ProjectUpdateRequest extends BaseFormRequest
|
|||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
|
||||||
'billable_rate' => array_merge([
|
'billable_rate' => array_merge([
|
||||||
'nullable',
|
'nullable',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class ProjectMemberStoreRequest extends BaseFormRequest
|
|||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
|
||||||
'billable_rate' => array_merge(
|
'billable_rate' => array_merge(
|
||||||
[
|
[
|
||||||
'nullable',
|
'nullable',
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class ProjectMemberUpdateRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
|
||||||
'billable_rate' => array_merge(
|
'billable_rate' => array_merge(
|
||||||
[
|
[
|
||||||
'nullable',
|
'nullable',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
// Filter by member ID. Without it, users who may view all time entries (owners, admins) get the entries of every member; pass your own member ID (from GET /v1/users/me/memberships) to get only yours
|
// Filter by member ID
|
||||||
'member_id' => [
|
'member_id' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||||
@@ -155,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
Rule::enum(TimeEntryType::class),
|
Rule::enum(TimeEntryType::class),
|
||||||
],
|
],
|
||||||
// Limit the number of returned time entries (default: 100)
|
// Limit the number of returned time entries (default: 150)
|
||||||
'limit' => [
|
'limit' => [
|
||||||
'integer',
|
'integer',
|
||||||
'min:1',
|
'min:1',
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
// ID of the organization member that the time entry should belong to (a member ID from GET /v1/users/me/memberships or the members list, not a user ID)
|
// ID of the organization member that the time entry should belong to
|
||||||
'member_id' => [
|
'member_id' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
@@ -86,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
|||||||
'date_format:Y-m-d\TH:i:s\Z',
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
'after_or_equal:start',
|
'after_or_equal:start',
|
||||||
],
|
],
|
||||||
// Whether time entry is billable. Not derived from the project: set it to the project's is_billable value to match the web app
|
// Whether time entry is billable
|
||||||
'billable' => [
|
'billable' => [
|
||||||
'required',
|
'required',
|
||||||
'boolean',
|
'boolean',
|
||||||
|
|||||||
@@ -43,10 +43,9 @@ class UserUpdateRequest extends BaseFormRequest
|
|||||||
'email' => [
|
'email' => [
|
||||||
'email:rfc,strict',
|
'email:rfc,strict',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(User::class, 'email')->query(function (Builder $query) {
|
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
return $query->where('is_placeholder', '=', false)
|
return $query->where('is_placeholder', '=', false);
|
||||||
->whereKeyNot($this->user->getKey());
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
'photo' => [
|
'photo' => [
|
||||||
|
|||||||
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
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of the API token, this ID is NOT a UUID */
|
/** @var string $id ID of the API token, this ID is NOT a UUID */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** @var string $name Name of the API token */
|
/** @var string $name Name of the API token */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** Whether the API token is revoked */
|
/** @var bool $revoked Whether the API token is revoked */
|
||||||
'revoked' => $this->resource->revoked,
|
'revoked' => $this->resource->revoked,
|
||||||
/** List of scopes that the API token has */
|
/** @var array<string> $scopes List of scopes that the API token has */
|
||||||
'scopes' => $this->resource->scopes,
|
'scopes' => $this->resource->scopes,
|
||||||
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||||
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||||
'expires_at' => $this->formatDateTime($this->resource->expires_at),
|
'expires_at' => $this->formatDateTime($this->resource->expires_at),
|
||||||
|
|||||||
@@ -29,15 +29,15 @@ class ApiTokenWithAccessTokenResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of the API token, this ID is NOT a UUID */
|
/** @var string $id ID of the API token, this ID is NOT a UUID */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** @var string $name Name of the API token */
|
/** @var string $name Name of the API token */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** Whether the API token is revoked */
|
/** @var bool $revoked Whether the API token is revoked */
|
||||||
'revoked' => $this->resource->revoked,
|
'revoked' => $this->resource->revoked,
|
||||||
/** List of scopes that the API token has */
|
/** @var array<string> $scopes List of scopes that the API token has */
|
||||||
'scopes' => $this->resource->scopes,
|
'scopes' => $this->resource->scopes,
|
||||||
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||||
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||||
'expires_at' => $this->formatDateTime($this->resource->expires_at),
|
'expires_at' => $this->formatDateTime($this->resource->expires_at),
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Client;
|
namespace App\Http\Resources\V1\Client;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class ClientCollection extends ResourceCollection
|
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ class ClientResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID */
|
/** @var string $id ID */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** Name */
|
/** @var string $name Name */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** Whether the client is archived */
|
/** @var bool $is_archived Whether the client is archived */
|
||||||
'is_archived' => $this->resource->is_archived,
|
'is_archived' => $this->resource->is_archived,
|
||||||
/** When the tag was created */
|
/** @var string $created_at When the tag was created */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||||
/** When the tag was last updated */
|
/** @var string $updated_at When the tag was last updated */
|
||||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
namespace App\Http\Resources\V1\Invitation;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class InvitationCollection extends ResourceCollection
|
class InvitationCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ class InvitationResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of the invitation */
|
/** @var string $id ID of the invitation */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** Email */
|
/** @var string $email Email */
|
||||||
'email' => $this->resource->email,
|
'email' => $this->resource->email,
|
||||||
/** Role */
|
/** @var string $role Role */
|
||||||
'role' => $this->resource->role,
|
'role' => $this->resource->role,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Member;
|
namespace App\Http\Resources\V1\Member;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class MemberCollection extends ResourceCollection
|
class MemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -22,19 +22,19 @@ class MemberResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of membership */
|
/** @var string $id ID of membership */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** ID of user */
|
/** @var string $id ID of user */
|
||||||
'user_id' => $this->resource->user->id,
|
'user_id' => $this->resource->user->id,
|
||||||
/** Name */
|
/** @var string $name Name */
|
||||||
'name' => $this->resource->user->name,
|
'name' => $this->resource->user->name,
|
||||||
/** Email */
|
/** @var string $email Email */
|
||||||
'email' => $this->resource->user->email,
|
'email' => $this->resource->user->email,
|
||||||
/** Role */
|
/** @var string $role Role */
|
||||||
'role' => $this->resource->role,
|
'role' => $this->resource->role,
|
||||||
/** Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
|
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
|
||||||
'is_placeholder' => $this->resource->user->is_placeholder,
|
'is_placeholder' => $this->resource->user->is_placeholder,
|
||||||
/** Billable rate in cents per hour */
|
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->resource->billable_rate,
|
'billable_rate' => $this->resource->billable_rate,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Member;
|
namespace App\Http\Resources\V1\Member;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class PersonalMembershipCollection extends ResourceCollection
|
class PersonalMembershipCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ class PersonalMembershipResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of membership */
|
/** @var string $id ID of membership */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
'organization' => [
|
'organization' => [
|
||||||
/** ID of organization */
|
/** @var string $id ID of organization */
|
||||||
'id' => $this->resource->organization->id,
|
'id' => $this->resource->organization->id,
|
||||||
/** Name of organization */
|
/** @var string $name Name of organization */
|
||||||
'name' => $this->resource->organization->name,
|
'name' => $this->resource->organization->name,
|
||||||
/** Currency code (ISO 4217) of organization */
|
/** @var string $currency Currency code (ISO 4217) of organization */
|
||||||
'currency' => $this->resource->organization->currency,
|
'currency' => $this->resource->organization->currency,
|
||||||
],
|
],
|
||||||
/** Role */
|
/** @var string $role Role */
|
||||||
'role' => $this->resource->role,
|
'role' => $this->resource->role,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,23 +43,23 @@ class OrganizationResource extends BaseResource
|
|||||||
$currencyService = app(CurrencyService::class);
|
$currencyService = app(CurrencyService::class);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
/** ID */
|
/** @var string $id ID */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** Name */
|
/** @var string $name Name */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** Personal organizations automatically created after registration */
|
/** @var bool $color Personal organizations automatically created after registration */
|
||||||
'is_personal' => $this->resource->personal_team,
|
'is_personal' => $this->resource->personal_team,
|
||||||
/** Billable rate in cents per hour */
|
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||||
/** Can members of the organization with role "employee" see the billable rates */
|
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
||||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||||
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||||
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
|
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
|
||||||
/** Prevent creating overlapping time entries (only new entries) */
|
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
||||||
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
||||||
/** Whether members of the organization can track breaks */
|
/** @var bool $breaks_enabled Whether members of the organization can track breaks */
|
||||||
'breaks_enabled' => $this->resource->breaks_enabled,
|
'breaks_enabled' => $this->resource->breaks_enabled,
|
||||||
/** Currency code (ISO 4217) */
|
/** @var string $currency Currency code (ISO 4217) */
|
||||||
'currency' => $this->resource->currency,
|
'currency' => $this->resource->currency,
|
||||||
/** @var string $currency_symbol Currency symbol */
|
/** @var string $currency_symbol Currency symbol */
|
||||||
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),
|
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Project;
|
namespace App\Http\Resources\V1\Project;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class ProjectCollection extends ResourceCollection
|
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
private bool $showBillableRates;
|
private bool $showBillableRates;
|
||||||
|
|
||||||
|
|||||||
@@ -30,25 +30,25 @@ class ProjectResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of project */
|
/** @var string $id ID of project */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** Name of project */
|
/** @var string $name Name of project */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** Color of project */
|
/** @var string $color Color of project */
|
||||||
'color' => $this->resource->color,
|
'color' => $this->resource->color,
|
||||||
/** @var string|null $client_id ID of client */
|
/** @var string|null $client_id ID of client */
|
||||||
'client_id' => $this->resource->client_id,
|
'client_id' => $this->resource->client_id,
|
||||||
/** Whether the client is archived */
|
/** @var bool $is_archived Whether the client is archived */
|
||||||
'is_archived' => $this->resource->is_archived,
|
'is_archived' => $this->resource->is_archived,
|
||||||
/** Billable rate in cents per hour */
|
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||||
/** Project time entries billable default */
|
/** @var bool $is_billable Project time entries billable default */
|
||||||
'is_billable' => $this->resource->is_billable,
|
'is_billable' => $this->resource->is_billable,
|
||||||
/** Estimated time in seconds */
|
/** @var int|null $estimated_time Estimated time in seconds */
|
||||||
'estimated_time' => $this->resource->estimated_time,
|
'estimated_time' => $this->resource->estimated_time,
|
||||||
/** Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||||
'spent_time' => $this->resource->spent_time,
|
'spent_time' => $this->resource->spent_time,
|
||||||
/** Whether the project is public */
|
/** @var bool $is_public Whether the project is public */
|
||||||
'is_public' => $this->resource->is_public,
|
'is_public' => $this->resource->is_public,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\ProjectMember;
|
namespace App\Http\Resources\V1\ProjectMember;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class ProjectMemberCollection extends ResourceCollection
|
class ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ class ProjectMemberResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of project member */
|
/** @var string $id ID of project member */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** Billable rate in cents per hour */
|
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->resource->billable_rate,
|
'billable_rate' => $this->resource->billable_rate,
|
||||||
/** ID of the organization member */
|
/** @var string $member_id ID of the organization member */
|
||||||
'member_id' => $this->resource->member_id,
|
'member_id' => $this->resource->member_id,
|
||||||
/** ID of the project */
|
/** @var string $project_id ID of the project */
|
||||||
'project_id' => $this->resource->project_id,
|
'project_id' => $this->resource->project_id,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,36 +21,36 @@ class DetailedReportResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of the report */
|
/** @var string $id ID of the report */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** Name */
|
/** @var string $name Name */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** Description */
|
/** @var string|null $email Description */
|
||||||
'description' => $this->resource->description,
|
'description' => $this->resource->description,
|
||||||
/** Whether the report can be accessed via an external link */
|
/** @var bool $is_public Whether the report can be accessed via an external link */
|
||||||
'is_public' => $this->resource->is_public,
|
'is_public' => $this->resource->is_public,
|
||||||
/** @var string|null $public_until Date until the report is public */
|
/** @var string|null $public_until Date until the report is public */
|
||||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||||
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
|
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
|
||||||
'shareable_link' => $this->resource->getShareableLink(),
|
'shareable_link' => $this->resource->getShareableLink(),
|
||||||
'properties' => [
|
'properties' => [
|
||||||
/** Type of first grouping */
|
/** @var string $group Type of first grouping */
|
||||||
'group' => $this->resource->properties->group->value,
|
'group' => $this->resource->properties->group->value,
|
||||||
/** Type of second grouping */
|
/** @var string $sub_group Type of second grouping */
|
||||||
'sub_group' => $this->resource->properties->subGroup->value,
|
'sub_group' => $this->resource->properties->subGroup->value,
|
||||||
/** Type of grouping of the historic aggregation (time chart) */
|
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
|
||||||
'history_group' => $this->resource->properties->historyGroup->value,
|
'history_group' => $this->resource->properties->historyGroup->value,
|
||||||
/** Start date of the report */
|
/** @var string $start Start date of the report */
|
||||||
'start' => $this->formatDateTime($this->resource->properties->start),
|
'start' => $this->formatDateTime($this->resource->properties->start),
|
||||||
/** End date of the report */
|
/** @var string $end End date of the report */
|
||||||
'end' => $this->formatDateTime($this->resource->properties->end),
|
'end' => $this->formatDateTime($this->resource->properties->end),
|
||||||
/** Whether the report is active */
|
/** @var bool|null $active Whether the report is active */
|
||||||
'active' => $this->resource->properties->active,
|
'active' => $this->resource->properties->active,
|
||||||
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
|
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
|
||||||
'member_ids' => $this->resource->properties->memberIds?->toArray(),
|
'member_ids' => $this->resource->properties->memberIds?->toArray(),
|
||||||
/** Filter by billable status */
|
/** @var bool|null $billable Filter by billable status */
|
||||||
'billable' => $this->resource->properties->billable,
|
'billable' => $this->resource->properties->billable,
|
||||||
/** Filter by time entry type */
|
/** @var string|null $time_entry_type Filter by time entry type */
|
||||||
'time_entry_type' => $this->resource->properties->timeEntryType?->value,
|
'time_entry_type' => $this->resource->properties->timeEntryType?->value,
|
||||||
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
|
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
|
||||||
'client_ids' => $this->resource->properties->clientIds?->toArray(),
|
'client_ids' => $this->resource->properties->clientIds?->toArray(),
|
||||||
@@ -58,18 +58,18 @@ class DetailedReportResource extends BaseResource
|
|||||||
'project_ids' => $this->resource->properties->projectIds?->toArray(),
|
'project_ids' => $this->resource->properties->projectIds?->toArray(),
|
||||||
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
|
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
|
||||||
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||||
/** Tag match type */
|
/** @var string|null $tag_match_type Tag match type */
|
||||||
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
|
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
|
||||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||||
/** Rounding type for time entries */
|
/** @var string|null $rounding_type Rounding type for time entries */
|
||||||
'rounding_type' => $this->resource->properties->roundingType?->value,
|
'rounding_type' => $this->resource->properties->roundingType?->value,
|
||||||
/** Rounding minutes for time entries */
|
/** @var int|null $rounding_minutes Rounding minutes for time entries */
|
||||||
'rounding_minutes' => $this->resource->properties->roundingMinutes,
|
'rounding_minutes' => $this->resource->properties->roundingMinutes,
|
||||||
],
|
],
|
||||||
/** Date when the report was created */
|
/** @var string $created_at Date when the report was created */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||||
/** Date when the report was last updated */
|
/** @var string $updated_at Date when the report was last updated */
|
||||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,13 +73,13 @@ class DetailedWithDataReportResource extends BaseResource
|
|||||||
$currencyService = app(CurrencyService::class);
|
$currencyService = app(CurrencyService::class);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
/** Name */
|
/** @var string $name Name */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** Description */
|
/** @var string|null $email Description */
|
||||||
'description' => $this->resource->description,
|
'description' => $this->resource->description,
|
||||||
/** @var string|null $public_until Date until the report is public */
|
/** @var string|null $public_until Date until the report is public */
|
||||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||||
/** Currency code (ISO 4217) */
|
/** @var string $currency Currency code (ISO 4217) */
|
||||||
'currency' => $this->resource->organization->currency,
|
'currency' => $this->resource->organization->currency,
|
||||||
/** @var NumberFormat $number_format Number format */
|
/** @var NumberFormat $number_format Number format */
|
||||||
'number_format' => $this->resource->organization->number_format->value,
|
'number_format' => $this->resource->organization->number_format->value,
|
||||||
@@ -94,15 +94,15 @@ class DetailedWithDataReportResource extends BaseResource
|
|||||||
/** @var TimeFormat $time_format Time format */
|
/** @var TimeFormat $time_format Time format */
|
||||||
'time_format' => $this->resource->organization->time_format->value,
|
'time_format' => $this->resource->organization->time_format->value,
|
||||||
'properties' => [
|
'properties' => [
|
||||||
/** Type of first grouping */
|
/** @var string $group Type of first grouping */
|
||||||
'group' => $this->resource->properties->group->value,
|
'group' => $this->resource->properties->group->value,
|
||||||
/** Type of second grouping */
|
/** @var string $sub_group Type of second grouping */
|
||||||
'sub_group' => $this->resource->properties->subGroup->value,
|
'sub_group' => $this->resource->properties->subGroup->value,
|
||||||
/** Type of grouping of the historic aggregation (time chart) */
|
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
|
||||||
'history_group' => $this->resource->properties->historyGroup->value,
|
'history_group' => $this->resource->properties->historyGroup->value,
|
||||||
/** Start date of the report */
|
/** @var string $start Start date of the report */
|
||||||
'start' => $this->formatDateTime($this->resource->properties->start),
|
'start' => $this->formatDateTime($this->resource->properties->start),
|
||||||
/** End date of the report */
|
/** @var string $end End date of the report */
|
||||||
'end' => $this->formatDateTime($this->resource->properties->end),
|
'end' => $this->formatDateTime($this->resource->properties->end),
|
||||||
],
|
],
|
||||||
/** @var array{
|
/** @var array{
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Report;
|
namespace App\Http\Resources\V1\Report;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class ReportCollection extends ResourceCollection
|
class ReportCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -21,21 +21,21 @@ class ReportResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of the report */
|
/** @var string $id ID of the report */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** Name */
|
/** @var string $name Name */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** Description */
|
/** @var string|null $email Description */
|
||||||
'description' => $this->resource->description,
|
'description' => $this->resource->description,
|
||||||
/** Whether the report can be accessed via an external link */
|
/** @var bool $is_public Whether the report can be accessed via an external link */
|
||||||
'is_public' => $this->resource->is_public,
|
'is_public' => $this->resource->is_public,
|
||||||
/** @var string|null $public_until Date until the report is public */
|
/** @var string|null $public_until Date until the report is public */
|
||||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||||
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
|
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
|
||||||
'shareable_link' => $this->resource->getShareableLink(),
|
'shareable_link' => $this->resource->getShareableLink(),
|
||||||
/** Date when the report was created */
|
/** @var string $created_at Date when the report was created */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||||
/** Date when the report was last updated */
|
/** @var string $updated_at Date when the report was last updated */
|
||||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Tag;
|
namespace App\Http\Resources\V1\Tag;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class TagCollection extends ResourceCollection
|
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ class TagResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID */
|
/** @var string $id ID */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** Name */
|
/** @var string $name Name */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** When the tag was created */
|
/** @var string $created_at When the tag was created */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||||
/** When the tag was last updated */
|
/** @var string $updated_at When the tag was last updated */
|
||||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Task;
|
namespace App\Http\Resources\V1\Task;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class TaskCollection extends ResourceCollection
|
class TaskCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -22,21 +22,21 @@ class TaskResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID */
|
/** @var string $id ID */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** Name */
|
/** @var string $name Name */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** Whether the task is done */
|
/** @var bool $is_done Whether the task is done */
|
||||||
'is_done' => $this->resource->is_done,
|
'is_done' => $this->resource->is_done,
|
||||||
/** ID of the project */
|
/** @var string $project_id ID of the project */
|
||||||
'project_id' => $this->resource->project_id,
|
'project_id' => $this->resource->project_id,
|
||||||
/** Estimated time in seconds */
|
/** @var int|null $estimated_time Estimated time in seconds */
|
||||||
'estimated_time' => $this->resource->estimated_time,
|
'estimated_time' => $this->resource->estimated_time,
|
||||||
/** Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
/** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||||
'spent_time' => $this->resource->spent_time,
|
'spent_time' => $this->resource->spent_time,
|
||||||
/** When the tag was created */
|
/** @var string $created_at When the tag was created */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||||
/** When the tag was last updated */
|
/** @var string $updated_at When the tag was last updated */
|
||||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\TimeEntry;
|
namespace App\Http\Resources\V1\TimeEntry;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class TimeEntryCollection extends ResourceCollection
|
class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ class TimeEntryResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of time entry */
|
/** @var string $id ID of time entry */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/**
|
/**
|
||||||
* Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
|
* @var string $start Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
|
||||||
*/
|
*/
|
||||||
'start' => $this->formatDateTime($this->resource->start),
|
'start' => $this->formatDateTime($this->resource->start),
|
||||||
/**
|
/**
|
||||||
@@ -35,19 +35,19 @@ class TimeEntryResource extends BaseResource
|
|||||||
'duration' => (int) $this->resource->getDuration()?->totalSeconds,
|
'duration' => (int) $this->resource->getDuration()?->totalSeconds,
|
||||||
/** @var string|null $description Description of time entry */
|
/** @var string|null $description Description of time entry */
|
||||||
'description' => $this->resource->description,
|
'description' => $this->resource->description,
|
||||||
/** ID of task */
|
/** @var string|null $task_id ID of task */
|
||||||
'task_id' => $this->resource->task_id,
|
'task_id' => $this->resource->task_id,
|
||||||
/** ID of project */
|
/** @var string|null $project_id ID of project */
|
||||||
'project_id' => $this->resource->project_id,
|
'project_id' => $this->resource->project_id,
|
||||||
/** ID of organization */
|
/** @var string $organization_id ID of organization */
|
||||||
'organization_id' => $this->resource->organization_id,
|
'organization_id' => $this->resource->organization_id,
|
||||||
/** ID of user */
|
/** @var string $user_id ID of user */
|
||||||
'user_id' => $this->resource->user_id,
|
'user_id' => $this->resource->user_id,
|
||||||
/** List of tag IDs */
|
/** @var array<string> $tags List of tag IDs */
|
||||||
'tags' => $this->resource->tags ?? [],
|
'tags' => $this->resource->tags ?? [],
|
||||||
/** Whether time entry is billable */
|
/** @var bool $billable Whether time entry is billable */
|
||||||
'billable' => $this->resource->billable,
|
'billable' => $this->resource->billable,
|
||||||
/** Type of the time entry (`work` time or a `break`) */
|
/** @var string $type Type of the time entry (`work` time or a `break`) */
|
||||||
'type' => $this->resource->type->value,
|
'type' => $this->resource->type->value,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,21 +22,21 @@ class UserResource extends BaseResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
/** ID of user */
|
/** @var string $id ID of user */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** Name of user */
|
/** @var string $name Name of user */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** Email of user */
|
/** @var string $email Email of user */
|
||||||
'email' => $this->resource->email,
|
'email' => $this->resource->email,
|
||||||
/** Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
|
/** @var string|null $pending_email Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
|
||||||
'pending_email' => $this->resource->pending_email,
|
'pending_email' => $this->resource->pending_email,
|
||||||
/** Profile photo URL */
|
/** @var string $profile_photo_url Profile photo URL */
|
||||||
'profile_photo_url' => $this->resource->profile_photo_url,
|
'profile_photo_url' => $this->resource->profile_photo_url,
|
||||||
/** Timezone (f.e. Europe/Berlin or America/New_York) */
|
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */
|
||||||
'timezone' => $this->resource->timezone,
|
'timezone' => $this->resource->timezone,
|
||||||
/** @var Weekday $week_start Starting day of the week */
|
/** @var Weekday $week_start Starting day of the week */
|
||||||
'week_start' => $this->resource->week_start->value,
|
'week_start' => $this->resource->week_start->value,
|
||||||
/** Whether to email the user when a time entry has been running for more than 8 hours */
|
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
|
||||||
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
|
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,15 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Concerns\AuditableWithoutOwner;
|
|
||||||
use Database\Factories\AuditFactory;
|
use Database\Factories\AuditFactory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string|null $actor_type
|
* @property string|null $user_type
|
||||||
* @property string|null $actor_id
|
* @property string|null $user_id
|
||||||
* @property string $event
|
* @property string $event
|
||||||
* @property string $auditable_type
|
* @property string $auditable_type
|
||||||
* @property string $auditable_id
|
* @property string $auditable_id
|
||||||
@@ -26,71 +22,13 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
|||||||
* @property string|null $ip_address
|
* @property string|null $ip_address
|
||||||
* @property string|null $user_agent
|
* @property string|null $user_agent
|
||||||
* @property string|null $tags
|
* @property string|null $tags
|
||||||
* @property string|null $owner_user_id
|
|
||||||
* @property string|null $owner_organization_id
|
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property-read User|null $ownerUser
|
|
||||||
* @property-read Organization|null $ownerOrganization
|
|
||||||
*
|
*
|
||||||
* @method static AuditFactory factory()
|
* @method static AuditFactory factory()
|
||||||
* @method static Builder<Audit> whereMissingOwner()
|
|
||||||
*/
|
*/
|
||||||
class Audit extends PackageAuditModel
|
class Audit extends PackageAuditModel
|
||||||
{
|
{
|
||||||
/** @use HasFactory<AuditFactory> */
|
/** @use HasFactory<AuditFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function ownerUser(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'owner_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Organization, $this>
|
|
||||||
*/
|
|
||||||
public function ownerOrganization(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Organization::class, 'owner_organization_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether audits of the given auditable type intentionally have no owner (see AuditableWithoutOwner).
|
|
||||||
*/
|
|
||||||
public static function isAuditableTypeWithoutOwner(string $auditableType): bool
|
|
||||||
{
|
|
||||||
$modelClass = Relation::getMorphedModel($auditableType) ?? $auditableType;
|
|
||||||
|
|
||||||
return is_subclass_of($modelClass, AuditableWithoutOwner::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auditable types (morph aliases) whose audits intentionally have no owner (see AuditableWithoutOwner).
|
|
||||||
*
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function getAuditableTypesWithoutOwner(): array
|
|
||||||
{
|
|
||||||
return collect(Relation::morphMap())
|
|
||||||
->filter(fn (string $modelClass): bool => is_subclass_of($modelClass, AuditableWithoutOwner::class))
|
|
||||||
->keys()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audits that have neither an owner organization nor an owner user, although their auditable type should have one.
|
|
||||||
* These are audits whose owner no longer exists or could not be determined (yet).
|
|
||||||
*
|
|
||||||
* @param Builder<Audit> $builder
|
|
||||||
*/
|
|
||||||
public function scopeWhereMissingOwner(Builder $builder): void
|
|
||||||
{
|
|
||||||
$builder->whereNull('owner_organization_id')
|
|
||||||
->whereNull('owner_user_id')
|
|
||||||
->whereNotIn('auditable_type', self::getAuditableTypesWithoutOwner());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
use OwenIt\Auditing\Auditable;
|
use OwenIt\Auditing\Auditable;
|
||||||
|
|
||||||
trait CustomAuditable
|
trait CustomAuditable
|
||||||
@@ -21,92 +19,4 @@ trait CustomAuditable
|
|||||||
{
|
{
|
||||||
$this->auditEvents = [];
|
$this->auditEvents = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The organization that owns the audited model.
|
|
||||||
* The audits of the model are deleted (via foreign key cascade) when the organization is deleted.
|
|
||||||
*/
|
|
||||||
public function getAuditOwnerOrganizationId(): ?string
|
|
||||||
{
|
|
||||||
if ($this instanceof AuditableThroughParent) {
|
|
||||||
$relation = $this->getAuditParentRelation();
|
|
||||||
$parentId = $this->getAttributes()[$relation->getForeignKeyName()] ?? null;
|
|
||||||
if ($parentId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: The loaded parent is only used if it still matches the foreign key and was loaded with the organization
|
|
||||||
$relationName = $relation->getRelationName();
|
|
||||||
if ($this->relationLoaded($relationName)) {
|
|
||||||
$parent = $this->getRelation($relationName);
|
|
||||||
if ($parent instanceof Model
|
|
||||||
&& $parent->getAttribute($relation->getOwnerKeyName()) === $parentId
|
|
||||||
&& array_key_exists('organization_id', $parent->getAttributes())) {
|
|
||||||
/** @var string|null $organizationId */
|
|
||||||
$organizationId = $parent->getAttributes()['organization_id'];
|
|
||||||
|
|
||||||
return $organizationId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var string|null $organizationId */
|
|
||||||
$organizationId = $relation->getRelated()->newQuery()
|
|
||||||
->toBase()
|
|
||||||
->where($relation->getOwnerKeyName(), $parentId)
|
|
||||||
->value('organization_id');
|
|
||||||
|
|
||||||
return $organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->getAttributes()['organization_id'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The user that owns the audited model.
|
|
||||||
* The audits of the model are deleted (via foreign key cascade) when the user is deleted.
|
|
||||||
*/
|
|
||||||
public function getAuditOwnerUserId(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Models that are the owner of their own audits can not record the deletion audit,
|
|
||||||
* since the audit would reference the already deleted model and therefore violate the foreign key.
|
|
||||||
*/
|
|
||||||
protected function isAuditOwnerOfItself(): bool
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int|string, string>
|
|
||||||
*/
|
|
||||||
public function getAuditEvents(): array
|
|
||||||
{
|
|
||||||
$events = $this->auditEvents ?? Config::get('audit.events', [
|
|
||||||
'created',
|
|
||||||
'updated',
|
|
||||||
'deleted',
|
|
||||||
'restored',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($this->isAuditOwnerOfItself()) {
|
|
||||||
$events = array_filter($events, fn (string $value, int|string $key): bool => (is_int($key) ? $value : $key) !== 'deleted', ARRAY_FILTER_USE_BOTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $events;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function transformAudit(array $data): array
|
|
||||||
{
|
|
||||||
$data['owner_organization_id'] = $this->getAuditOwnerOrganizationId();
|
|
||||||
$data['owner_user_id'] = $this->getAuditOwnerUserId();
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 User $user
|
||||||
* @property-read Collection<int, ProjectMember> $projectMembers
|
* @property-read Collection<int, ProjectMember> $projectMembers
|
||||||
* @property-read Collection<int, TimeEntry> $timeEntries
|
* @property-read Collection<int, TimeEntry> $timeEntries
|
||||||
* @property-read Collection<int, Goal> $goals
|
|
||||||
*
|
*
|
||||||
* @method static MemberFactory factory()
|
* @method static MemberFactory factory()
|
||||||
*/
|
*/
|
||||||
@@ -79,14 +78,4 @@ class Member extends Pivot implements AuditableContract
|
|||||||
{
|
{
|
||||||
return $this->hasMany(ProjectMember::class, 'member_id');
|
return $this->hasMany(ProjectMember::class, 'member_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Goals whose progress counts the time entries of this member.
|
|
||||||
*
|
|
||||||
* @return HasMany<Goal, $this>
|
|
||||||
*/
|
|
||||||
public function goals(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Goal::class, 'member_id');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,16 +96,6 @@ class Organization extends Model implements AuditableContract
|
|||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getAuditOwnerOrganizationId(): ?string
|
|
||||||
{
|
|
||||||
return $this->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function isAuditOwnerOfItself(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the users that belong to the team.
|
* Get all the users that belong to the team.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Concerns\AuditableThroughParent;
|
|
||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use Database\Factories\ProjectMemberFactory;
|
use Database\Factories\ProjectMemberFactory;
|
||||||
@@ -30,7 +29,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
|
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
|
||||||
* @method static ProjectMemberFactory factory()
|
* @method static ProjectMemberFactory factory()
|
||||||
*/
|
*/
|
||||||
class ProjectMember extends Model implements AuditableContract, AuditableThroughParent
|
class ProjectMember extends Model implements AuditableContract
|
||||||
{
|
{
|
||||||
use CustomAuditable;
|
use CustomAuditable;
|
||||||
|
|
||||||
@@ -83,12 +82,4 @@ class ProjectMember extends Model implements AuditableContract, AuditableThrough
|
|||||||
$query->whereBelongsTo($organization, 'organization');
|
$query->whereBelongsTo($organization, 'organization');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Project, $this>
|
|
||||||
*/
|
|
||||||
public function getAuditParentRelation(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->project();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,16 +124,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
'send_time_entry_still_running_email' => true,
|
'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.
|
* Get the URL to the user's profile photo.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ use App\Models\Task;
|
|||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\BillingContract;
|
use App\Service\BillingContract;
|
||||||
use App\Service\GoalsContract;
|
|
||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
use App\Service\IpLookup\NoIpLookupService;
|
use App\Service\IpLookup\NoIpLookupService;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
@@ -104,7 +103,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
// Extensions
|
// Extensions
|
||||||
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
||||||
$this->app->bind(BillingContract::class);
|
$this->app->bind(BillingContract::class);
|
||||||
$this->app->bind(GoalsContract::class);
|
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
|
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
|
||||||
|
|||||||
@@ -43,15 +43,6 @@ class BillingContract
|
|||||||
return null;
|
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
|
* Check if the organization is blocked
|
||||||
* A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial
|
* 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\Events\BeforeOrganizationDeletion;
|
||||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Goal;
|
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
use App\Models\Passport\RefreshToken;
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\Report;
|
use App\Models\Report;
|
||||||
@@ -77,9 +75,6 @@ class DeletionService
|
|||||||
// Delete all reports
|
// Delete all reports
|
||||||
Report::query()->whereBelongsTo($organization, 'organization')->delete();
|
Report::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||||
|
|
||||||
// Delete all goals
|
|
||||||
Goal::query()->whereBelongsTo($organization, 'organization')->delete();
|
|
||||||
|
|
||||||
// Reset the current organization
|
// Reset the current organization
|
||||||
$organization->owner()
|
$organization->owner()
|
||||||
->where('current_team_id', $organization->getKey())
|
->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->accessTokens()->delete();
|
||||||
$user->authCodes()->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\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
class ReportPropertiesDto implements Castable
|
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->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->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
|
||||||
$dto->active = $data->active;
|
$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->billable = $data->billable;
|
||||||
$dto->clientIds = $data->clientIds !== null ? TimeEntryFilter::idArrayToCollection($data->clientIds) : null;
|
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
|
||||||
$dto->projectIds = $data->projectIds !== null ? TimeEntryFilter::idArrayToCollection($data->projectIds) : null;
|
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
|
||||||
$dto->tagIds = $data->tagIds !== null ? TimeEntryFilter::idArrayToCollection($data->tagIds) : null;
|
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
|
||||||
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : 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->group = TimeEntryAggregationType::from($data->group);
|
||||||
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
||||||
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
$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
|
* @param array<mixed>|null $memberIds
|
||||||
*/
|
*/
|
||||||
public function setMemberIds(?array $memberIds): void
|
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
|
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
|
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
|
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
|
public function setTagMatchType(?TagMatchType $tagMatchType): void
|
||||||
@@ -221,6 +242,6 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public function setTaskIds(?array $taskIds): void
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -229,6 +229,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
'Project',
|
'Project',
|
||||||
'Description',
|
'Description',
|
||||||
'User',
|
'User',
|
||||||
|
'Group',
|
||||||
'Email',
|
'Email',
|
||||||
'Tags',
|
'Tags',
|
||||||
'Start Date',
|
'Start Date',
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
|||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
use App\Models\Goal;
|
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
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();
|
$member->delete();
|
||||||
|
|
||||||
if ($isPlaceholder) {
|
if ($isPlaceholder) {
|
||||||
@@ -188,30 +181,6 @@ class MemberService
|
|||||||
->whereBelongsToOrganization($organization)
|
->whereBelongsToOrganization($organization)
|
||||||
->whereBelongsTo($fromMember, 'member')
|
->whereBelongsTo($fromMember, 'member')
|
||||||
->delete();
|
->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:create',
|
||||||
'reports:update',
|
'reports:update',
|
||||||
'reports:delete',
|
'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:view',
|
||||||
'invoices:create',
|
'invoices:create',
|
||||||
'invoices:update',
|
'invoices:update',
|
||||||
@@ -154,14 +146,6 @@ class PermissionStore
|
|||||||
'reports:create',
|
'reports:create',
|
||||||
'reports:update',
|
'reports:update',
|
||||||
'reports:delete',
|
'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:view',
|
||||||
'invoices:create',
|
'invoices:create',
|
||||||
'invoices:update',
|
'invoices:update',
|
||||||
@@ -222,14 +206,6 @@ class PermissionStore
|
|||||||
'reports:create',
|
'reports:create',
|
||||||
'reports:update',
|
'reports:update',
|
||||||
'reports:delete',
|
'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:view',
|
||||||
'invoices:create',
|
'invoices:create',
|
||||||
'invoices:update',
|
'invoices:update',
|
||||||
@@ -257,10 +233,6 @@ class PermissionStore
|
|||||||
'time-entries:update:own',
|
'time-entries:update:own',
|
||||||
'time-entries:delete:own',
|
'time-entries:delete:own',
|
||||||
'organizations:view',
|
'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.',
|
'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.',
|
||||||
],
|
],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user