Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
6b84ba67cd add tanstack table, add clients table 2024-09-27 15:05:44 +02:00
285 changed files with 2098 additions and 11167 deletions

22
.env.ci
View File

@@ -6,13 +6,12 @@ APP_URL=http://localhost
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
SESSION_SECURE_COOKIE=false SESSION_SECURE_COOKIE=false
# Logging
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql_test DB_CONNECTION=pgsql_test
DB_TEST_HOST=127.0.0.1 DB_TEST_HOST=127.0.0.1
DB_TEST_PORT=5432 DB_TEST_PORT=5432
DB_TEST_DATABASE=laravel DB_TEST_DATABASE=laravel
@@ -21,21 +20,26 @@ DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log BROADCAST_DRIVER=log
CACHE_DRIVER=file CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync QUEUE_CONNECTION=sync
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
# Mail MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log MAIL_MAILER=log
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
# Filesystems S3_ACCESS_KEY_ID=
FILESYSTEM_DISK=local S3_SECRET_ACCESS_KEY=
PUBLIC_FILESYSTEM_DISK=public S3_REGION=us-east-1
S3_BUCKET=
# Services S3_USE_PATH_STYLE_ENDPOINT=false
GOTENBERG_URL=http://0.0.0.0:3000
PUSHER_APP_ID= PUSHER_APP_ID=
PUSHER_APP_KEY= PUSHER_APP_KEY=

View File

@@ -4,15 +4,15 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true APP_DEBUG=true
APP_URL=https://solidtime.test APP_URL=https://solidtime.test
AUDITING_ENABLED=true AUDITING_ENABLED=true
SUPER_ADMINS=admin@example.com SUPER_ADMINS=admin@example.com
# Logging
LOG_CHANNEL=single LOG_CHANNEL=single
LOG_DEPRECATIONS_CHANNEL=deprecation LOG_DEPRECATIONS_CHANNEL=deprecation
LOG_LEVEL=debug LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=pgsql DB_HOST=pgsql
DB_PORT=5432 DB_PORT=5432
DB_DATABASE=laravel DB_DATABASE=laravel
@@ -31,7 +31,12 @@ QUEUE_CONNECTION=sync
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
# Mail MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST=mailpit MAIL_HOST=mailpit
MAIL_PORT=1025 MAIL_PORT=1025
@@ -49,7 +54,7 @@ PUSHER_PORT=443
PUSHER_SCHEME=https PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1 PUSHER_APP_CLUSTER=mt1
# Filesystems # Storage
FILESYSTEM_DISK=s3 FILESYSTEM_DISK=s3
PUBLIC_FILESYSTEM_DISK=s3 PUBLIC_FILESYSTEM_DISK=s3
S3_ACCESS_KEY_ID=sail S3_ACCESS_KEY_ID=sail
@@ -60,9 +65,6 @@ S3_URL=http://storage.solidtime.test/local
S3_ENDPOINT=http://storage.solidtime.test S3_ENDPOINT=http://storage.solidtime.test
S3_USE_PATH_STYLE_ENDPOINT=true S3_USE_PATH_STYLE_ENDPOINT=true
# Services
GOTENBERG_URL=http://gotenberg:3000
VITE_HOST_NAME=vite.solidtime.test VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"

View File

@@ -1,6 +1,4 @@
APP_NAME=solidtime APP_NAME=solidtime
APP_VERSION=0.0.0
APP_BUILD=0
VITE_APP_NAME=solidtime VITE_APP_NAME=solidtime
APP_ENV=production APP_ENV=production
APP_DEBUG=false APP_DEBUG=false

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: solidtime-io

View File

@@ -20,55 +20,15 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production"
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
- name: "Copy .env template for production"
run: cp .env.production .env && cat .env
- name: "Checkout billing extension" - name: "Checkout billing extension"
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:

View File

@@ -0,0 +1,90 @@
on:
push:
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-public.yml'
- 'docker/prod/**'
workflow_dispatch:
name: Build - Public (Release)
jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@v4
- name: "Copy .env template for production"
run: cp .env.production .env
- name: "Install dependencies"
uses: php-actions/composer@v6
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
with:
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Build"
run: npm run build
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
solidtime/solidtime
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Build and push"
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -3,8 +3,6 @@ on:
branches: branches:
- main - main
- develop - develop
tags:
- '*'
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-public.yml' - '.github/workflows/build-public.yml'
@@ -25,49 +23,9 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production" - name: "Copy .env template for production"
run: | run: cp .env.production .env
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Install dependencies" - name: "Install dependencies"
uses: php-actions/composer@v6 uses: php-actions/composer@v6

View File

@@ -20,15 +20,7 @@ jobs:
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --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: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -28,11 +28,6 @@ We also have an examples repository [here](https://github.com/solidtime-io/self-
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/) If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
## Issues & Feature Requests
If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
## Contributing ## Contributing
This project is in a very early stage. The structure and APIs are still subject to change and not stable. This project is in a very early stage. The structure and APIs are still subject to change and not stable.
@@ -40,8 +35,6 @@ Therefore, we do not currently accept any contributions, unless you are a member
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions. As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
## Security ## Security
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.

View File

@@ -43,9 +43,9 @@ class CreateNewUser implements CreatesNewUsers
'email' => [ 'email' => [
'required', 'required',
'string', 'string',
'email:rfc,strict', 'email',
'max:255', 'max:255',
UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder { new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false); return $builder->where('is_placeholder', '=', false);
}), }),
@@ -82,7 +82,7 @@ class CreateNewUser implements CreatesNewUsers
} }
$user = null; $user = null;
$organization = null; $organization = null;
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void { DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) {
$user = User::create([ $user = User::create([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],

View File

@@ -35,7 +35,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
'required', 'required',
'email', 'email',
'max:255', 'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) { (new UniqueEloquent(User::class, 'email'))->ignore($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);
}), }),

View File

@@ -38,7 +38,7 @@ class AddOrganizationMember implements AddsTeamMembers
AddingTeamMember::dispatch($organization, $newOrganizationMember); AddingTeamMember::dispatch($organization, $newOrganizationMember);
DB::transaction(function () use ($organization, $newOrganizationMember, $role): void { DB::transaction(function () use ($organization, $newOrganizationMember, $role) {
$organization->users()->attach( $organization->users()->attach(
$newOrganizationMember, ['role' => $role] $newOrganizationMember, ['role' => $role]
); );
@@ -71,10 +71,10 @@ class AddOrganizationMember implements AddsTeamMembers
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
ExistsEloquent::make(User::class, 'email', function (Builder $builder) { (new ExistsEloquent(User::class, 'email', function (Builder $builder) {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false); return $builder->where('is_placeholder', '=', false);
})->withMessage(__('We were unable to find a registered user with this email address.')), }))->withMessage(__('We were unable to find a registered user with this email address.')),
], ],
'role' => [ 'role' => [
'required', 'required',
@@ -93,7 +93,7 @@ class AddOrganizationMember implements AddsTeamMembers
*/ */
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
{ {
return function ($validator) use ($team, $email): void { return function ($validator) use ($team, $email) {
$validator->errors()->addIf( $validator->errors()->addIf(
$team->hasRealUserWithEmail($email), $team->hasRealUserWithEmail($email),
'email', 'email',

View File

@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use App\Service\ApiService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class SelfHostCheckForUpdateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:check-for-update';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$apiService = app(ApiService::class);
$latestVersion = $apiService->checkForUpdate();
if ($latestVersion === null) {
$this->error('Failed to check for update, check the logs for more information.');
return self::FAILURE;
}
// Note: Cache for 13 hours, because the command runs twice daily (every 12 hours).
Cache::put('latest_version', $latestVersion, 60 * 60 * 12);
return self::SUCCESS;
}
}

View File

@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use App\Service\ApiService;
use Illuminate\Console\Command;
class SelfHostTelemetryCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:telemetry';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$apiService = app(ApiService::class);
$success = $apiService->telemetry();
if (! $success) {
$this->error('Failed to send telemetry data, check the logs for more information.');
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@@ -54,7 +54,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
$query->where('is_placeholder', '=', false); $query->where('is_placeholder', '=', false);
}) })
->orderBy('created_at', 'asc') ->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void { ->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
/** @var Collection<int, TimeEntry> $timeEntries */ /** @var Collection<int, TimeEntry> $timeEntries */
foreach ($timeEntries as $timeEntry) { foreach ($timeEntries as $timeEntry) {
$user = $timeEntry->user; $user = $timeEntry->user;

View File

@@ -17,14 +17,6 @@ class Kernel extends ConsoleKernel
$schedule->command('time-entry:send-still-running-mails') $schedule->command('time-entry:send-still-running-mails')
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails')) ->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes(); ->everyTenMinutes();
$schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily();
$schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily();
} }
/** /**

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Maatwebsite\Excel\Excel;
enum ExportFormat: string
{
case CSV = 'csv';
case PDF = 'pdf';
case XLSX = 'xlsx';
case ODS = 'ods';
public function getFileExtension(): string
{
return match ($this) {
self::CSV => 'csv',
self::PDF => 'pdf',
self::XLSX => 'xlsx',
self::ODS => 'ods',
};
}
public function getExportPackageType(): string
{
return match ($this) {
self::CSV => Excel::CSV,
self::PDF => Excel::MPDF,
self::XLSX => Excel::XLSX,
self::ODS => Excel::ODS,
};
}
}

View File

@@ -4,12 +4,8 @@ declare(strict_types=1);
namespace App\Enums; namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryAggregationType: string enum TimeEntryAggregationType: string
{ {
use LaravelEnumHelper;
case Day = 'day'; case Day = 'day';
case Week = 'week'; case Week = 'week';
case Month = 'month'; case Month = 'month';
@@ -21,16 +17,6 @@ enum TimeEntryAggregationType: string
case Billable = 'billable'; case Billable = 'billable';
case Description = 'description'; case Description = 'description';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{
return match ($timeEntryAggregationTypeInterval) {
TimeEntryAggregationTypeInterval::Day => TimeEntryAggregationType::Day,
TimeEntryAggregationTypeInterval::Week => TimeEntryAggregationType::Week,
TimeEntryAggregationTypeInterval::Month => TimeEntryAggregationType::Month,
TimeEntryAggregationTypeInterval::Year => TimeEntryAggregationType::Year,
};
}
public function toInterval(): ?TimeEntryAggregationTypeInterval public function toInterval(): ?TimeEntryAggregationTypeInterval
{ {
return match ($this) { return match ($this) {

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ class Handler extends ExceptionHandler
*/ */
public function register(): void public function register(): void
{ {
$this->reportable(function (Throwable $e): void { $this->reportable(function (Throwable $e) {
// //
}); });
} }

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Extensions\Scramble; namespace App\Extensions\Scramble;
use App\Http\Resources\PaginatedResourceCollection; use App\Http\Resources\PaginatedResourceCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use Dedoc\Scramble\Extensions\TypeToSchemaExtension; use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
use Dedoc\Scramble\Support\Generator\Response; use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Schema; use Dedoc\Scramble\Support\Generator\Schema;
@@ -45,49 +44,39 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
return null; return null;
} }
$newType = new OpenApiObjectType; $type = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType)); $type->addProperty('data', (new ArrayType)->setItems($collectingType));
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) { $type->addProperty(
$newType->addProperty( 'links',
'meta', (new OpenApiObjectType)
(new OpenApiObjectType) ->addProperty('first', (new StringType)->nullable(true))
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.')) ->addProperty('last', (new StringType)->nullable(true))
->setRequired(['total']) ->addProperty('prev', (new StringType)->nullable(true))
); ->addProperty('next', (new StringType)->nullable(true))
$newType->setRequired(['data', 'meta']); ->setRequired(['first', 'last', 'prev', 'next'])
} else { );
$newType->addProperty( $type->addProperty(
'links', 'meta',
(new OpenApiObjectType) (new OpenApiObjectType)
->addProperty('first', (new StringType)->nullable(true)) ->addProperty('current_page', new IntegerType)
->addProperty('last', (new StringType)->nullable(true)) ->addProperty('from', (new IntegerType)->nullable(true))
->addProperty('prev', (new StringType)->nullable(true)) ->addProperty('last_page', new IntegerType)
->addProperty('next', (new StringType)->nullable(true)) ->addProperty('links', (new ArrayType)->setItems(
->setRequired(['first', 'last', 'prev', 'next']) (new OpenApiObjectType)
); ->addProperty('url', (new StringType)->nullable(true))
$newType->addProperty( ->addProperty('label', new StringType)
'meta', ->addProperty('active', new BooleanType)
(new OpenApiObjectType) ->setRequired(['url', 'label', 'active'])
->addProperty('current_page', new IntegerType) )->setDescription('Generated paginator links.'))
->addProperty('from', (new IntegerType)->nullable(true)) ->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
->addProperty('last_page', new IntegerType) ->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
->addProperty('links', (new ArrayType)->setItems( ->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
(new OpenApiObjectType) ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->addProperty('url', (new StringType)->nullable(true)) ->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
->addProperty('label', new StringType) );
->addProperty('active', new BooleanType) $type->setRequired(['data', 'links', 'meta']);
->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; return $type;
} }
/** /**

View File

@@ -70,7 +70,6 @@ class OrganizationResource extends Resource
'nullable', 'nullable',
'integer', 'integer',
'gt:0', 'gt:0',
'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\DateTimePicker::make('created_at') Forms\Components\DateTimePicker::make('created_at')
@@ -123,7 +122,7 @@ class OrganizationResource extends Resource
->persistent() ->persistent()
->send(); ->send();
return response()->streamDownload(function () use ($file): void { return response()->streamDownload(function () use ($file) {
echo Storage::disk(config('filesystems.private'))->get($file); echo Storage::disk(config('filesystems.private'))->get($file);
}, 'export.zip'); }, 'export.zip');
} catch (\Exception $exception) { } catch (\Exception $exception) {
@@ -138,7 +137,7 @@ class OrganizationResource extends Resource
}), }),
Action::make('Import') Action::make('Import')
->icon('heroicon-o-inbox-arrow-down') ->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data): void { ->action(function (Organization $record, array $data) {
try { try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']); $file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
if ($file === null) { if ($file === null) {

View File

@@ -29,7 +29,6 @@ class ProjectMemberResource extends Resource
'nullable', 'nullable',
'integer', 'integer',
'gt:0', 'gt:0',
'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\Select::make('user_id') Forms\Components\Select::make('user_id')

View File

@@ -45,7 +45,6 @@ class ProjectResource extends Resource
'nullable', 'nullable',
'integer', 'integer',
'gt:0', 'gt:0',
'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\Select::make('organization_id') Forms\Components\Select::make('organization_id')

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use Filament\Widgets\Widget;
use Illuminate\Support\Facades\Cache;
class ServerOverview extends Widget
{
protected static string $view = 'filament.widgets.server-overview';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
/** @var string|null $currentVersion */
$currentVersion = config('app.version');
/** @var string|null $build */
$build = config('app.build');
$latestVersion = Cache::get('latest_version', null);
$needsUpdate = false;
if ($latestVersion !== null && $currentVersion !== null && version_compare($latestVersion, $currentVersion) > 0) {
$needsUpdate = true;
}
return [
'version' => $currentVersion,
'build' => $build,
'environment' => config('app.env'),
'currentVersion' => $latestVersion,
'needsUpdate' => $needsUpdate,
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Models\Organization; use App\Models\Organization;
use App\Service\BillingContract;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -44,9 +43,4 @@ class Controller extends \App\Http\Controllers\Controller
{ {
return $this->permissionStore->has($organization, $permission); return $this->permissionStore->has($organization, $permission);
} }
protected function canAccessPremiumFeatures(Organization $organization): bool
{
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
}
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest; use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource; use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization; use App\Models\Organization;
@@ -24,9 +23,7 @@ class OrganizationController extends Controller
{ {
$this->checkPermission($organization, 'organizations:view'); $this->checkPermission($organization, 'organizations:view');
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; return new OrganizationResource($organization);
return new OrganizationResource($organization, $showBillableRate);
} }
/** /**
@@ -42,9 +39,6 @@ class OrganizationController extends Controller
$organization->name = $request->input('name'); $organization->name = $request->input('name');
$oldBillableRate = $organization->billable_rate; $oldBillableRate = $organization->billable_rate;
if ($request->has('employees_can_see_billable_rates')) {
$organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates');
}
$organization->billable_rate = $request->getBillableRate(); $organization->billable_rate = $request->getBillableRate();
$organization->save(); $organization->save();
@@ -52,6 +46,6 @@ class OrganizationController extends Controller
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization); $billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
} }
return new OrganizationResource($organization, true); return new OrganizationResource($organization);
} }
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Project\ProjectIndexRequest; use App\Http\Requests\V1\Project\ProjectIndexRequest;
use App\Http\Requests\V1\Project\ProjectStoreRequest; use App\Http\Requests\V1\Project\ProjectStoreRequest;
@@ -14,7 +13,6 @@ use App\Http\Resources\V1\Project\ProjectResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -62,9 +60,7 @@ class ProjectController extends Controller
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default')); $projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; return new ProjectCollection($projects);
return new ProjectCollection($projects, $showBillableRate);
} }
/** /**
@@ -78,12 +74,9 @@ class ProjectController extends Controller
{ {
$this->checkPermission($organization, 'projects:view', $project); $this->checkPermission($organization, 'projects:view', $project);
// Note: There is currently no need to check if a user is a member of the project,
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
$project->load('organization'); $project->load('organization');
return new ProjectResource($project, true); return new ProjectResource($project);
} }
/** /**
@@ -102,13 +95,10 @@ class ProjectController extends Controller
$project->is_billable = (bool) $request->input('is_billable'); $project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate(); $project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id'); $project->client_id = $request->input('client_id');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$project->organization()->associate($organization); $project->organization()->associate($organization);
$project->save(); $project->save();
return new ProjectResource($project, true); return new ProjectResource($project);
} }
/** /**
@@ -127,29 +117,16 @@ class ProjectController extends Controller
if ($request->has('is_archived')) { if ($request->has('is_archived')) {
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null; $project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
} }
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$oldBillableRate = $project->billable_rate; $oldBillableRate = $project->billable_rate;
$clientIdChanged = false;
$project->billable_rate = $request->getBillableRate(); $project->billable_rate = $request->getBillableRate();
if ($project->client_id !== $request->input('client_id')) { $project->client_id = $request->input('client_id');
$project->client_id = $request->input('client_id');
$clientIdChanged = true;
}
$project->save(); $project->save();
if ($oldBillableRate !== $request->getBillableRate()) { if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProject($project); $billableRateService->updateTimeEntriesBillableRateForProject($project);
} }
if ($clientIdChanged) {
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($project, 'project')
->update(['client_id' => $project->client_id]);
}
return new ProjectResource($project, true); return new ProjectResource($project);
} }
/** /**
@@ -170,8 +147,8 @@ class ProjectController extends Controller
throw new EntityStillInUseApiException('project', 'time_entry'); throw new EntityStillInUseApiException('project', 'time_entry');
} }
DB::transaction(function () use (&$project): void { DB::transaction(function () use (&$project) {
$project->members->each(function (ProjectMember $member): void { $project->members->each(function (ProjectMember $member) {
$member->delete(); $member->delete();
}); });

View File

@@ -79,9 +79,6 @@ class TaskController extends Controller
$task = new Task; $task = new Task;
$task->name = $request->input('name'); $task->name = $request->input('name');
$task->project_id = $request->input('project_id'); $task->project_id = $request->input('project_id');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime();
}
$task->organization()->associate($organization); $task->organization()->associate($organization);
$task->save(); $task->save();
@@ -99,9 +96,6 @@ class TaskController extends Controller
{ {
$this->checkPermission($organization, 'tasks:update', $task); $this->checkPermission($organization, 'tasks:update', $task);
$task->name = $request->input('name'); $task->name = $request->input('name');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime();
}
if ($request->has('is_done')) { if ($request->has('is_done')) {
$task->done_at = $request->getIsDone() ? Carbon::now() : null; $task->done_at = $request->getIsDone() ? Carbon::now() : null;
} }

View File

@@ -4,51 +4,28 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection; use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryResource; use App\Http\Resources\V1\TimeEntry\TimeEntryResource;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport;
use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Gotenberg\Exceptions\GotenbergApiErrored;
use Gotenberg\Exceptions\NoOutputFileInResponse;
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
use GuzzleHttp\Client;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller class TimeEntryController extends Controller
{ {
@@ -61,13 +38,11 @@ class TimeEntryController extends Controller
} }
/** /**
* Get time entries in organization * Get all time entries in organization
* *
* If you only need time entries for a specific user, you can filter by `user_id`. * If you only need time entries for a specific user, you can filter by `user_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter. * Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
* *
* @return TimeEntryCollection<TimeEntryResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getTimeEntries * @operationId getTimeEntries
@@ -82,16 +57,27 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->orderBy('start', 'desc');
$totalCount = $timeEntriesQuery->count(); $filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
$filter->addEndFilter($request->input('end'));
$filter->addActiveFilter($request->input('active'));
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$limit = $request->getLimit(); $limit = $request->has('limit') ? (int) $request->input('limit', 100) : 100;
if ($limit > 1000) { if ($limit > 1000) {
$limit = 1000; $limit = 1000;
} }
$timeEntriesQuery->limit($limit); $timeEntriesQuery->limit($limit);
$timeEntriesQuery->skip($request->getOffset());
$timeEntries = $timeEntriesQuery->get(); $timeEntries = $timeEntriesQuery->get();
@@ -125,121 +111,7 @@ class TimeEntryController extends Controller
} }
} }
return (new TimeEntryCollection($timeEntries)) return new TimeEntryCollection($timeEntries);
->additional([
'meta' => [
'total' => $totalCount,
],
]);
}
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
$filter->addEndFilter($request->input('end'));
$filter->addActiveFilter($request->input('active'));
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
return $filter->get();
}
/**
* Export time entries in organization
*
* @throws AuthorizationException|PdfRendererIsNotConfiguredException|FeatureIsNotAvailableInFreePlanApiException
*
* @operationId exportTimeEntries
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request): JsonResponse
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$timezone = $user->timezone;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([
'task',
'client',
'project',
'user',
'tagsRelation',
]);
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
if ($format === ExportFormat::CSV) {
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone);
$export->export();
} elseif ($format === ExportFormat::PDF) {
if (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$html = Blade::render($viewFile, ['timeEntries' => $timeEntriesQuery->get()]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
$client = new Client([
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
config('services.gotenberg.basic_auth_username'),
config('services.gotenberg.basic_auth_password'),
] : null,
]);
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->pdfa('PDF/A-3b')
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->html(Stream::string('body', $html));
$tempFolder = TemporaryDirectory::make();
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
Storage::disk(config('filesystems.private'))
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
[
'visibility' => 'private',
]
);
}
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
]);
} }
/** /**
@@ -274,7 +146,7 @@ class TimeEntryController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $aggregationService): array
{ {
/** @var Member|null $member */ /** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
@@ -283,146 +155,7 @@ class TimeEntryController extends Controller
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$user = $this->user();
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
$group1Type,
$group2Type,
$user->timezone,
$user->week_start,
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd()
);
return [
'data' => $aggregatedData,
];
}
/**
* Export aggregated time entries in organization
*
* @operationId exportAggregatedTimeEntries
*
* @throws AuthorizationException
* @throws PdfRendererIsNotConfiguredException
* @throws GotenbergApiErrored
* @throws NoOutputFileInResponse
* @throws FeatureIsNotAvailableInFreePlanApiException
*/
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(),
$group,
$subGroup,
$user->timezone,
$user->week_start,
false,
$request->getStart(),
$request->getEnd()
);
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(),
$request->getHistoryGroup(),
null,
$user->timezone,
$user->week_start,
true,
$request->getStart(),
$request->getEnd()
);
$currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
if ($format === ExportFormat::PDF) {
if (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
$client = new Client([
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
config('services.gotenberg.basic_auth_username'),
config('services.gotenberg.basic_auth_password'),
] : null,
]);
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$html = Blade::render($viewFile, [
'aggregatedData' => $aggregatedData,
'dataHistoryChart' => $dataHistoryChart,
'currency' => $currency,
'group' => $group,
'subGroup' => $subGroup,
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->pdfa('PDF/A-3b')
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->html(Stream::string('body', $html));
$tempFolder = TemporaryDirectory::make();
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
Storage::disk(config('filesystems.private'))
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
[
'visibility' => 'private',
]
);
}
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
]);
}
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization'); ->whereBelongsTo($organization, 'organization');
@@ -437,8 +170,27 @@ class TimeEntryController extends Controller
$filter->addTaskIdsFilter($request->input('task_ids')); $filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids')); $filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable')); $filter->addBillableFilter($request->input('billable'));
$timeEntriesQuery = $filter->get();
return $filter->get(); $user = $this->user();
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$aggregatedData = $aggregationService->getAggregatedTimeEntries(
$timeEntriesQuery,
$group1Type,
$group2Type,
$user->timezone,
$user->week_start,
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd()
);
return [
'data' => $aggregatedData,
];
} }
/** /**
@@ -463,16 +215,7 @@ class TimeEntryController extends Controller
throw new TimeEntryStillRunningApiException; throw new TimeEntryStillRunningApiException;
} }
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null; $client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
$client = $project?->client;
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$timeEntry = new TimeEntry; $timeEntry = new TimeEntry;
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
@@ -507,38 +250,16 @@ class TimeEntryController extends Controller
throw new TimeEntryCanNotBeRestartedApiException; throw new TimeEntryCanNotBeRestartedApiException;
} }
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$project = null;
if ($request->has('project_id')) { if ($request->has('project_id')) {
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null; $client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
$client = $project?->client;
$timeEntry->client()->associate($client); $timeEntry->client()->associate($client);
} }
$task = null;
if ($request->has('task_id')) {
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
}
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
$timeEntry->description = $request->input('description', $timeEntry->description) ?? ''; $timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save(); $timeEntry->save();
if ($oldProject !== null) {
RecalculateSpentTimeForProject::dispatch($oldProject);
}
if ($oldTask !== null) {
RecalculateSpentTimeForTask::dispatch($oldTask);
}
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
RecalculateSpentTimeForTask::dispatch($task);
}
return new TimeEntryResource($timeEntry); return new TimeEntryResource($timeEntry);
} }
@@ -558,37 +279,22 @@ class TimeEntryController extends Controller
$timeEntries = TimeEntry::query() $timeEntries = TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->with([
'project',
'task',
])
->whereIn('id', $ids) ->whereIn('id', $ids)
->get(); ->get();
$changes = $request->validated('changes'); $changes = $request->validated('changes');
if ($request->has('changes.description')) {
$changes['description'] = $request->input('changes.description') ?? '';
}
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) { if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
throw new AuthorizationException; throw new AuthorizationException;
} }
$project = null;
$client = null; $client = null;
$overwriteClient = false; $overwriteClient = false;
if ($request->has('changes.project_id')) { if ($request->has('changes.project_id')) {
$project = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id')) : null; $client = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id'))->client : null;
$client = $project?->client;
$overwriteClient = true; $overwriteClient = true;
} }
$task = null;
if ($request->has('changes.task_id')) {
$task = $request->input('changes.task_id') !== null ? Task::findOrFail((string) $request->input('changes.task_id')) : null;
}
$success = new Collection; $success = new Collection;
$error = new Collection; $error = new Collection;
@@ -607,32 +313,12 @@ class TimeEntryController extends Controller
continue; continue;
} }
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$timeEntry->fill($changes); $timeEntry->fill($changes);
// If project is changed, but task is not, we remove the old task from the time entry
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
$timeEntry->task()->disassociate();
}
if ($overwriteClient) { if ($overwriteClient) {
$timeEntry->client()->associate($client); $timeEntry->client()->associate($client);
} }
$timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save(); $timeEntry->save();
if ($oldTask !== null) {
RecalculateSpentTimeForTask::dispatch($oldTask);
}
if ($oldProject !== null) {
RecalculateSpentTimeForProject::dispatch($oldProject);
}
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
RecalculateSpentTimeForTask::dispatch($task);
}
$success->push($id); $success->push($id);
} }
@@ -657,81 +343,9 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry); $this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
} }
$project = $timeEntry->project;
$task = $timeEntry->task;
$timeEntry->delete(); $timeEntry->delete();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
return response() return response()
->json(null, 204); ->json(null, 204);
} }
/**
* Delete multiple time entries
*
* @throws AuthorizationException
*
* @operationId deleteTimeEntries
*/
public function destroyMultiple(Organization $organization, TimeEntryDestroyMultipleRequest $request): JsonResponse
{
$this->checkAnyPermission($organization, ['time-entries:delete:all', 'time-entries:delete:own']);
$canDeleteAll = $this->hasPermission($organization, 'time-entries:delete:all');
$ids = $request->validated('ids');
$timeEntries = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->with([
'project',
'task',
])
->whereIn('id', $ids)
->get();
$success = new Collection;
$error = new Collection;
foreach ($ids as $id) {
/** @var TimeEntry|null $timeEntry */
$timeEntry = $timeEntries->firstWhere('id', $id);
if ($timeEntry === null) {
// Note: ID wrong or time entry in different organization
$error->push($id);
continue;
}
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id);
continue;
}
$project = $timeEntry->project;
$task = $timeEntry->task;
$timeEntry->delete();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$success->push($id);
}
return response()->json([
'success' => $success->toArray(),
'error' => $error->toArray(),
]);
}
} }

View File

@@ -48,8 +48,6 @@ class HealthCheckController extends Controller
return response() return response()
->json([ ->json([
'ip_address' => $ipAddress, 'ip_address' => $ipAddress,
'url' => $request->url(),
'path' => $request->path(),
'hostname' => $hostname, 'hostname' => $hostname,
'timestamp' => Carbon::now()->timestamp, 'timestamp' => Carbon::now()->timestamp,
'date_time_utc' => Carbon::now('UTC')->toDateTimeString(), 'date_time_utc' => Carbon::now('UTC')->toDateTimeString(),

View File

@@ -20,7 +20,6 @@ class ClientIndexRequest extends FormRequest
'page' => [ 'page' => [
'integer', 'integer',
'min:1', 'min:1',
'max:2147483647',
], ],
'archived' => [ 'archived' => [
'string', 'string',

View File

@@ -29,10 +29,10 @@ class ClientStoreRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder { (new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.client_name_already_exists'), }))->withCustomTranslation('validation.client_name_already_exists'),
], ],
]; ];
} }

View File

@@ -31,10 +31,10 @@ class ClientUpdateRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder { (new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'), }))->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'),
], ],
'is_archived' => [ 'is_archived' => [
'boolean', 'boolean',

View File

@@ -29,10 +29,10 @@ class InvitationStoreRequest extends FormRequest
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder { (new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
/** @var Builder<OrganizationInvitation> $builder */ /** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.invitation_already_exists'), }))->withCustomTranslation('validation.invitation_already_exists'),
], ],
'role' => [ 'role' => [
'required', 'required',

View File

@@ -31,7 +31,6 @@ class MemberUpdateRequest extends FormRequest
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
], ],
]; ];
} }

View File

@@ -30,10 +30,6 @@ class OrganizationUpdateRequest extends FormRequest
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
],
'employees_can_see_billable_rates' => [
'boolean',
], ],
]; ];
} }

View File

@@ -20,7 +20,6 @@ class ProjectIndexRequest extends FormRequest
'page' => [ 'page' => [
'integer', 'integer',
'min:1', 'min:1',
'max:2147483647',
], ],
'archived' => [ 'archived' => [
'string', 'string',

View File

@@ -32,10 +32,10 @@ class ProjectStoreRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder { (new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.project_name_already_exists'), }))->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
'required', 'required',
@@ -51,22 +51,13 @@ class ProjectStoreRequest extends FormRequest
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
], ],
// ID of the client
'client_id' => [ 'client_id' => [
'nullable', 'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
], ],
]; ];
} }
@@ -77,11 +68,4 @@ class ProjectStoreRequest extends FormRequest
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
} }
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
} }

View File

@@ -32,10 +32,10 @@ class ProjectUpdateRequest extends FormRequest
'required', 'required',
'string', 'string',
'max:255', 'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder { (new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'), }))->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
'required', 'required',
@@ -52,23 +52,15 @@ class ProjectUpdateRequest extends FormRequest
], ],
'client_id' => [ 'client_id' => [
'nullable', 'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
'billable_rate' => [ 'billable_rate' => [
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
], ],
]; ];
} }
@@ -86,11 +78,4 @@ class ProjectUpdateRequest extends FormRequest
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
} }
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
} }

View File

@@ -26,16 +26,16 @@ class ProjectMemberStoreRequest extends FormRequest
return [ return [
'member_id' => [ 'member_id' => [
'required', 'required',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
'billable_rate' => [ 'billable_rate' => [
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
], ],
]; ];
} }

View File

@@ -25,7 +25,6 @@ class ProjectMemberUpdateRequest extends FormRequest
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
], ],
]; ];
} }

View File

@@ -29,10 +29,10 @@ class TagStoreRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder { (new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.tag_name_already_exists'), }))->withCustomTranslation('validation.tag_name_already_exists'),
], ],
]; ];
} }

View File

@@ -30,10 +30,10 @@ class TagUpdateRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder { (new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'), }))->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'),
], ],
]; ];
} }

View File

@@ -27,7 +27,8 @@ class TaskIndexRequest extends FormRequest
{ {
return [ return [
'project_id' => [ 'project_id' => [
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization'); $builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -36,7 +37,7 @@ class TaskIndexRequest extends FormRequest
} }
return $builder; return $builder;
})->uuid(), }),
], ],
'done' => [ 'done' => [
'string', 'string',

View File

@@ -31,32 +31,18 @@ class TaskStoreRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder { (new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->where('project_id', '=', $this->input('project_id')); return $builder->where('project_id', '=', $this->input('project_id'));
})->withCustomTranslation('validation.task_name_already_exists'), }))->withCustomTranslation('validation.task_name_already_exists'),
], ],
'project_id' => [ 'project_id' => [
'required', 'required',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
], ],
]; ];
} }
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
} }

View File

@@ -30,21 +30,14 @@ class TaskUpdateRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder { (new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->where('project_id', '=', $this->task->project_id); return $builder->where('project_id', '=', $this->task->project_id);
})->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'), }))->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'),
], ],
'is_done' => [ 'is_done' => [
'boolean', 'boolean',
], ],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
]; ];
} }
@@ -54,11 +47,4 @@ class TaskUpdateRequest extends FormRequest
return $this->boolean('is_done'); return $this->boolean('is_done');
} }
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
} }

View File

@@ -1,193 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
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\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateExportRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
'group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'sub_group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'history_group' => [
'required',
'nullable',
Rule::enum(TimeEntryAggregationTypeInterval::class),
],
// Filter by member ID
'member_id' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [
'array',
'min:1',
],
'member_ids.*' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by user ID
'user_id' => [
'string',
ExistsEloquent::make(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization);
})->uuid(),
],
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
'array',
'min:1',
],
'project_ids.*' => [
'string',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
'array',
'min:1',
],
'tag_ids.*' => [
'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
'min:1',
],
'task_ids.*' => [
'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [
'required',
'string',
'date_format:Y-m-d\TH:i:s\Z',
'before:end',
],
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'end' => [
'required',
'string',
'date_format:Y-m-d\TH:i:s\Z',
],
// Filter by active status (active means has no end date, is still running)
'active' => [
'string',
'in:true,false',
],
// Filter by billable status
'billable' => [
'string',
'in:true,false',
],
'fill_gaps_in_time_groups' => [
'string',
'in:true,false',
],
];
}
public function getGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('group'));
}
public function getSubGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('sub_group'));
}
public function getHistoryGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::fromInterval(TimeEntryAggregationTypeInterval::from($this->input('history_group')));
}
public function getStart(): Carbon
{
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
}
public function getEnd(): Carbon
{
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
}
public function getFormatValue(): ExportFormat
{
return ExportFormat::from($this->validated('format'));
}
}

View File

@@ -45,10 +45,11 @@ class TimeEntryAggregateRequest extends FormRequest
// Filter by member ID // Filter by member ID
'member_id' => [ 'member_id' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [ 'member_ids' => [
@@ -57,19 +58,21 @@ class TimeEntryAggregateRequest extends FormRequest
], ],
'member_ids.*' => [ 'member_ids.*' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// Filter by user ID // Filter by user ID
'user_id' => [ 'user_id' => [
'string', 'string',
ExistsEloquent::make(User::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization); return $builder->belongsToOrganization($this->organization);
})->uuid(), }),
], ],
// Filter by project IDs, project IDs are OR combined // Filter by project IDs, project IDs are OR combined
'project_ids' => [ 'project_ids' => [
@@ -78,10 +81,11 @@ class TimeEntryAggregateRequest extends FormRequest
], ],
'project_ids.*' => [ 'project_ids.*' => [
'string', 'string',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// Filter by client IDs, client IDs are OR combined // Filter by client IDs, client IDs are OR combined
'client_ids' => [ 'client_ids' => [
@@ -90,22 +94,24 @@ class TimeEntryAggregateRequest extends FormRequest
], ],
'client_ids.*' => [ 'client_ids.*' => [
'string', 'string',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// Filter by tag IDs, tag IDs are OR combined // Filter by tag IDs, tag IDs are AND combined
'tag_ids' => [ 'tag_ids' => [
'array', 'array',
'min:1', 'min:1',
], ],
'tag_ids.*' => [ 'tag_ids.*' => [
'string', 'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
@@ -114,9 +120,10 @@ class TimeEntryAggregateRequest extends FormRequest
], ],
'task_ids.*' => [ 'task_ids.*' => [
'string', 'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [ 'start' => [

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryDestroyMultipleRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'ids' => [
'required',
'array',
],
'ids.*' => [
'string',
'uuid',
],
];
}
}

View File

@@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
// Filter by member ID
'member_id' => [
'string',
'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [
'array',
'min:1',
],
'member_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
'array',
'min:1',
],
'project_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
'array',
'min:1',
],
'tag_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
'min:1',
],
'task_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [
'nullable',
'string',
'date_format:Y-m-d\TH:i:s\Z',
'before:end',
],
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'end' => [
'nullable',
'string',
'date_format:Y-m-d\TH:i:s\Z',
],
// Filter by active status (active means has no end date, is still running)
'active' => [
'string',
'in:true,false',
],
// Filter by billable status
'billable' => [
'string',
'in:true,false',
],
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
'min:1',
'max:500',
],
// Filter makes sure that only time entries of a whole date are returned
'only_full_dates' => [
'string',
'in:true,false',
],
];
}
public function getOnlyFullDates(): bool
{
return $this->input('only_full_dates', 'false') === 'true';
}
public function getFormatValue(): ExportFormat
{
return ExportFormat::from($this->validated('format'));
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -31,10 +30,11 @@ class TimeEntryIndexRequest extends FormRequest
// Filter by member ID // Filter by member ID
'member_id' => [ 'member_id' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [ 'member_ids' => [
@@ -43,22 +43,11 @@ class TimeEntryIndexRequest extends FormRequest
], ],
'member_ids.*' => [ 'member_ids.*' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
], ],
// Filter by project IDs, project IDs are OR combined // Filter by project IDs, project IDs are OR combined
'project_ids' => [ 'project_ids' => [
@@ -67,22 +56,24 @@ class TimeEntryIndexRequest extends FormRequest
], ],
'project_ids.*' => [ 'project_ids.*' => [
'string', 'string',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// Filter by tag IDs, tag IDs are OR combined // Filter by tag IDs, tag IDs are AND combined
'tag_ids' => [ 'tag_ids' => [
'array', 'array',
'min:1', 'min:1',
], ],
'tag_ids.*' => [ 'tag_ids.*' => [
'string', 'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
@@ -91,10 +82,11 @@ class TimeEntryIndexRequest extends FormRequest
], ],
'task_ids.*' => [ 'task_ids.*' => [
'string', 'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [ 'start' => [
@@ -125,12 +117,6 @@ class TimeEntryIndexRequest extends FormRequest
'min:1', 'min:1',
'max:500', 'max:500',
], ],
// Skip the first n time entries (default: 0)
'offset' => [
'integer',
'min:0',
'max:2147483647',
],
// Filter makes sure that only time entries of a whole date are returned // Filter makes sure that only time entries of a whole date are returned
'only_full_dates' => [ 'only_full_dates' => [
'string', 'string',
@@ -143,14 +129,4 @@ class TimeEntryIndexRequest extends FormRequest
{ {
return $this->input('only_full_dates', 'false') === 'true'; return $this->input('only_full_dates', 'false') === 'true';
} }
public function getLimit(): int
{
return $this->has('limit') ? (int) $this->validated('limit', 100) : 100;
}
public function getOffset(): int
{
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
}
} }

View File

@@ -31,33 +31,36 @@ class TimeEntryStoreRequest extends FormRequest
'member_id' => [ 'member_id' => [
'required', 'required',
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
'project_id' => [ 'project_id' => [
'nullable', 'nullable',
'string', 'string',
'uuid',
'required_with:task_id', 'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
'task_id' => [ 'task_id' => [
'nullable', 'nullable',
'string', 'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { (new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization') return $builder->whereBelongsTo($this->organization, 'organization')
->where('project_id', $this->input('project_id')); ->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')), }))->withMessage(__('validation.task_belongs_to_project')),
], ],
// Start of time entry (ISO 8601 format, UTC timezone) // Start of time entry (ISO 8601 format, UTC timezone)
'start' => [ 'start' => [
@@ -87,10 +90,12 @@ class TimeEntryStoreRequest extends FormRequest
'array', 'array',
], ],
'tags.*' => [ 'tags.*' => [
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { 'string',
'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
]; ];
} }

View File

@@ -42,34 +42,37 @@ class TimeEntryUpdateMultipleRequest extends FormRequest
// ID of the organization member that the time entry should belong to // ID of the organization member that the time entry should belong to
'changes.member_id' => [ 'changes.member_id' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// ID of the project that the time entry should belong to // ID of the project that the time entry should belong to
'changes.project_id' => [ 'changes.project_id' => [
'nullable', 'nullable',
'string', 'string',
'uuid',
'required_with:task_id', 'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
'changes.task_id' => [ 'changes.task_id' => [
'nullable', 'nullable',
'string', 'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { (new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization') return $builder->whereBelongsTo($this->organization, 'organization')
->where('project_id', $this->input('changes.project_id')); ->where('project_id', $this->input('changes.project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')), }))->withMessage(__('validation.task_belongs_to_project')),
], ],
// Whether time entry is billable // Whether time entry is billable
'changes.billable' => [ 'changes.billable' => [
@@ -88,10 +91,11 @@ class TimeEntryUpdateMultipleRequest extends FormRequest
], ],
'changes.tags.*' => [ 'changes.tags.*' => [
'string', 'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
]; ];
} }

View File

@@ -30,34 +30,37 @@ class TimeEntryUpdateRequest extends FormRequest
// ID of the organization member that the time entry should belong to // ID of the organization member that the time entry should belong to
'member_id' => [ 'member_id' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// ID of the project that the time entry should belong to // ID of the project that the time entry should belong to
'project_id' => [ 'project_id' => [
'nullable', 'nullable',
'string', 'string',
'uuid',
'required_with:task_id', 'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
'task_id' => [ 'task_id' => [
'nullable', 'nullable',
'string', 'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { (new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization') return $builder->whereBelongsTo($this->organization, 'organization')
->where('project_id', $this->input('project_id')); ->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')), }))->withMessage(__('validation.task_belongs_to_project')),
], ],
// Start of time entry (ISO 8601 format, UTC timezone) // Start of time entry (ISO 8601 format, UTC timezone)
'start' => [ 'start' => [
@@ -86,10 +89,11 @@ class TimeEntryUpdateRequest extends FormRequest
], ],
'tags.*' => [ 'tags.*' => [
'string', 'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { 'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), }),
], ],
]; ];
} }

View File

@@ -13,20 +13,6 @@ use Illuminate\Http\Request;
*/ */
class OrganizationResource extends BaseResource class OrganizationResource extends BaseResource
{ {
private bool $showBillableRate;
/**
* Create a new resource instance.
*
* @return void
*/
public function __construct(Organization $resource, bool $showBillableRate)
{
parent::__construct($resource);
$this->showBillableRate = $showBillableRate;
}
/** /**
* Transform the resource into an array. * Transform the resource into an array.
* *
@@ -42,9 +28,7 @@ class OrganizationResource extends BaseResource
/** @var bool $color 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,
/** @var int|null $billable_rate 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->resource->billable_rate,
/** @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,
]; ];
} }
} }

View File

@@ -5,39 +5,14 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Project; namespace App\Http\Resources\V1\Project;
use App\Http\Resources\PaginatedResourceCollection; use App\Http\Resources\PaginatedResourceCollection;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
private bool $showBillableRates;
/** /**
* @param LengthAwarePaginator<Project> $resource * The resource that this resource collects.
*/
public function __construct($resource, bool $showBillableRates)
{
parent::__construct($resource);
$this->showBillableRates = $showBillableRates;
}
protected function collects(): ?string
{
return null;
}
/**
* Transform the resource collection into an array.
* *
* @return array<array<string, string|bool|int|null>> * @var string
*/ */
public function toArray(Request $request): array public $collects = ProjectResource::class;
{
return $this->collection->map(function (Project $project) use ($request): array {
return (new ProjectResource($project, $this->showBillableRates))
->toArray($request);
})->all();
}
} }

View File

@@ -13,15 +13,6 @@ use Illuminate\Http\Request;
*/ */
class ProjectResource extends BaseResource class ProjectResource extends BaseResource
{ {
private bool $showBillableRate;
public function __construct(Project $resource, bool $showBillableRate)
{
parent::__construct($resource);
$this->showBillableRate = $showBillableRate;
}
/** /**
* Transform the resource into an array. * Transform the resource into an array.
* *
@@ -41,13 +32,9 @@ class ProjectResource extends BaseResource
/** @var bool $is_archived 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,
/** @var int|null $billable_rate 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->resource->billable_rate,
/** @var bool $is_billable 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,
/** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time,
/** @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,
]; ];
} }
} }

View File

@@ -30,10 +30,6 @@ class TaskResource extends BaseResource
'is_done' => $this->resource->is_done, 'is_done' => $this->resource->is_done,
/** @var string $project_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,
/** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time,
/** @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,
/** @var string $created_at 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),
/** @var string $updated_at When the tag was last updated */ /** @var string $updated_at When the tag was last updated */

View File

@@ -4,10 +4,9 @@ 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 implements PaginatedResourceCollection class TimeEntryCollection extends ResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Project;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecalculateSpentTimeForProject implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public Project $project;
/**
* Create a new job instance.
*/
public function __construct(Project $project)
{
$this->project = $project;
}
/**
* Execute the job.
*
* @throws Exception
*/
public function handle(): void
{
$this->project->setComputedAttributeValue('spent_time');
if ($this->project->isDirty()) {
$this->project->save();
}
}
}

View File

@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Task;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecalculateSpentTimeForTask implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public Task $task;
/**
* Create a new job instance.
*/
public function __construct(Task $task)
{
$this->task = $task;
}
/**
* Execute the job.
*
* @throws Exception
*/
public function handle(): void
{
$this->task->setComputedAttributeValue('spent_time');
if ($this->task->isDirty()) {
$this->task->save();
}
}
}

View File

@@ -29,7 +29,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $currency * @property string $currency
* @property int|null $billable_rate * @property int|null $billable_rate
* @property string $user_id * @property string $user_id
* @property bool $employees_can_see_billable_rates
* @property User $owner * @property User $owner
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
@@ -59,7 +58,6 @@ class Organization extends JetstreamTeam implements AuditableContract
'name' => 'string', 'name' => 'string',
'personal_team' => 'boolean', 'personal_team' => 'boolean',
'currency' => 'string', 'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
]; ];
/** /**

View File

@@ -15,8 +15,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -29,8 +27,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property bool $is_public * @property bool $is_public
* @property bool $is_billable * @property bool $is_billable
* @property-read bool $is_archived * @property-read bool $is_archived
* @property int|null $estimated_time
* @property int $spent_time
* @property Carbon|null $archived_at * @property Carbon|null $archived_at
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
@@ -44,7 +40,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/ */
class Project extends Model implements AuditableContract class Project extends Model implements AuditableContract
{ {
use ComputedAttributes;
use CustomAuditable; use CustomAuditable;
/** @use HasFactory<ProjectFactory> */ /** @use HasFactory<ProjectFactory> */
@@ -61,8 +56,6 @@ class Project extends Model implements AuditableContract
'name' => 'string', 'name' => 'string',
'color' => 'string', 'color' => 'string',
'archived_at' => 'datetime', 'archived_at' => 'datetime',
'estimated_time' => 'integer',
'spent_time' => 'integer',
]; ];
/** /**
@@ -74,68 +67,6 @@ class Project extends Model implements AuditableContract
'is_billable' => false, 'is_billable' => false,
]; ];
/**
* The attributes that are computed. (f.e. for performance reasons)
* These attributes can be regenerated at any time.
*
* @var string[]
*/
protected array $computed = [
'spent_time',
];
/**
* Attributes to exclude from the Audit.
*
* @var array<string>
*/
protected array $auditExclude = [
'spent_time',
];
public function getSpentTimeComputed(): ?int
{
if ($this->hasAttribute('spent_time_computed')) {
return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];
} else {
/** @var object{ spent_time: string } $result */
$result = $this->timeEntries()
->whereNotNull('end')
->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time')
->first();
return (int) $result->spent_time;
}
}
/**
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
*
* @param Builder<Project> $builder
* @param array<string> $attributes Attributes that will be generated.
* @return Builder<Project>
*/
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
{
if (in_array('spent_time', $attributes, true)) {
$builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum');
}
return $builder;
}
/**
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
*
* @param Builder<Project> $builder
* @param array<string> $attributes Attributes that will be validated.
* @return Builder<Project>
*/
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
{
return $this->scopeComputedAttributesGenerate($builder, $attributes);
}
/** /**
* @return BelongsTo<Organization, Project> * @return BelongsTo<Organization, Project>
*/ */

View File

@@ -7,14 +7,11 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\TagFactory; use Database\Factories\TagFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
/** /**
* @property string $id * @property string $id
@@ -22,7 +19,6 @@ use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
* @property string $organization_id * @property string $organization_id
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Collection<TimeEntry> $timeEntries
* @property-read Organization $organization * @property-read Organization $organization
* *
* @method static TagFactory factory() * @method static TagFactory factory()
@@ -34,7 +30,6 @@ class Tag extends Model implements AuditableContract
/** @use HasFactory<TagFactory> */ /** @use HasFactory<TagFactory> */
use HasFactory; use HasFactory;
use HasJsonRelationships;
use HasUuids; use HasUuids;
/** /**
@@ -53,14 +48,4 @@ class Tag extends Model implements AuditableContract
{ {
return $this->belongsTo(Organization::class, 'organization_id'); return $this->belongsTo(Organization::class, 'organization_id');
} }
/**
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
*
* @return HasManyJson<TimeEntry, $this>
*/
public function timeEntries(): HasManyJson
{
return $this->hasManyJson(TimeEntry::class, 'tags');
}
} }

View File

@@ -15,8 +15,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -25,8 +23,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $project_id * @property string $project_id
* @property string $organization_id * @property string $organization_id
* @property Carbon|null $done_at * @property Carbon|null $done_at
* @property int|null $estimated_time
* @property int $spent_time
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Project $project * @property-read Project $project
@@ -38,7 +34,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/ */
class Task extends Model implements AuditableContract class Task extends Model implements AuditableContract
{ {
use ComputedAttributes;
use CustomAuditable; use CustomAuditable;
/** @use HasFactory<TaskFactory> */ /** @use HasFactory<TaskFactory> */
@@ -53,72 +48,9 @@ class Task extends Model implements AuditableContract
*/ */
protected $casts = [ protected $casts = [
'name' => 'string', 'name' => 'string',
'estimated_time' => 'integer',
'done_at' => 'datetime', 'done_at' => 'datetime',
]; ];
/**
* The attributes that are computed. (f.e. for performance reasons)
* These attributes can be regenerated at any time.
*
* @var string[]
*/
protected array $computed = [
'spent_time',
];
/**
* Attributes to exclude from the Audit.
*
* @var array<string>
*/
protected array $auditExclude = [
'spent_time',
];
public function getSpentTimeComputed(): ?int
{
if ($this->hasAttribute('spent_time_computed')) {
return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];
} else {
/** @var object{ spent_time: string } $result */
$result = $this->timeEntries()
->whereNotNull('end')
->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time')
->first();
return (int) $result->spent_time;
}
}
/**
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
*
* @param Builder<Task> $builder
* @param array<string> $attributes Attributes that will be generated.
* @return Builder<Task>
*/
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
{
if (in_array('spent_time', $attributes, true)) {
$builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum');
}
return $builder;
}
/**
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
*
* @param Builder<Task> $builder
* @param array<string> $attributes Attributes that will be validated.
* @return Builder<Task>
*/
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
{
return $this->scopeComputedAttributesGenerate($builder, $attributes);
}
/** /**
* @return BelongsTo<Project, Task> * @return BelongsTo<Project, Task>
*/ */

View File

@@ -10,16 +10,12 @@ use App\Service\BillableRateService;
use Carbon\CarbonInterval; use Carbon\CarbonInterval;
use Database\Factories\TimeEntryFactory; use Database\Factories\TimeEntryFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Korridor\LaravelComputedAttributes\ComputedAttributes; use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
/** /**
* @property string $id * @property string $id
@@ -45,7 +41,6 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property-read Client|null $client * @property-read Client|null $client
* @property string|null $task_id * @property string|null $task_id
* @property-read Task|null $task * @property-read Task|null $task
* @property-read Collection<Tag> $tagsRelation
* *
* @method Builder<TimeEntry> hasTag(Tag $tag) * @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory() * @method static TimeEntryFactory factory()
@@ -58,7 +53,6 @@ class TimeEntry extends Model implements AuditableContract
/** @use HasFactory<TimeEntryFactory> */ /** @use HasFactory<TimeEntryFactory> */
use HasFactory; use HasFactory;
use HasJsonRelationships;
use HasUuids; use HasUuids;
/** /**
@@ -85,16 +79,6 @@ class TimeEntry extends Model implements AuditableContract
*/ */
protected array $computed = [ protected array $computed = [
'billable_rate', 'billable_rate',
'client_id',
];
/**
* Attributes to exclude from the Audit.
*
* @var array<string>
*/
protected array $auditExclude = [
'billable_rate',
]; ];
public function getBillableRateComputed(): ?int public function getBillableRateComputed(): ?int
@@ -102,44 +86,6 @@ class TimeEntry extends Model implements AuditableContract
return app(BillableRateService::class)->getBillableRateForTimeEntry($this); return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
} }
public function getClientIdComputed(): ?string
{
return $this->project_id === null ? null : $this->project->client_id;
}
/**
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
*
* @param Builder<TimeEntry> $builder
* @param array<string> $attributes Attributes that will be generated.
* @return Builder<TimeEntry>
*/
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
{
if (in_array('client_id', $attributes, true)) {
$builder->with([
'project' => function (Relation $builder): void {
/** @var Builder<Project> $builder */
$builder->select('id', 'client_id');
},
]);
}
return $builder;
}
/**
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
*
* @param Builder<TimeEntry> $builder
* @param array<string> $attributes Attributes that will be validated.
* @return Builder<TimeEntry>
*/
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
{
return $this->scopeComputedAttributesGenerate($builder, $attributes);
}
public function getDuration(): ?CarbonInterval public function getDuration(): ?CarbonInterval
{ {
return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end); return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);
@@ -202,14 +148,4 @@ class TimeEntry extends Model implements AuditableContract
{ {
return $this->belongsTo(Client::class, 'client_id'); return $this->belongsTo(Client::class, 'client_id');
} }
/**
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
*
* @return BelongsToJson<Tag, $this>
*/
public function tagsRelation(): BelongsToJson
{
return $this->belongsToJson(Tag::class, 'tags');
}
} }

View File

@@ -25,9 +25,7 @@ use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams; use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
use Laravel\Passport\Token;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -180,22 +178,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return $this->hasMany(ProjectMember::class, 'user_id'); return $this->hasMany(ProjectMember::class, 'user_id');
} }
/**
* @return HasMany<Token>
*/
public function accessTokens(): HasMany
{
return $this->hasMany(Token::class);
}
/**
* @return HasMany<AuthCode>
*/
public function authCodes(): HasMany
{
return $this->hasMany(AuthCode::class);
}
/** /**
* @param Builder<User> $builder * @param Builder<User> $builder
*/ */

View File

@@ -28,6 +28,7 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -80,20 +81,19 @@ class AppServiceProvider extends ServiceProvider
}); });
// Scramble // Scramble
Scramble::extendOpenApi(function (OpenApi $openApi): void { Scramble::extendOpenApi(function (OpenApi $openApi) {
$openApi->secure( $openApi->secure(
SecurityScheme::oauth2() SecurityScheme::oauth2()
->flow('authorizationCode', function (OAuthFlow $flow): void { ->flow('authorizationCode', function (OAuthFlow $flow) {
$flow $flow
->authorizationUrl('https://solidtime.test/oauth/authorize'); ->authorizationUrl('https://solidtime.test/oauth/authorize');
}) })
); );
}); });
if (config('app.force_https', false)) { if (config('app.force_https', false) || App::isProduction()) {
URL::forceScheme('https'); URL::forceScheme('https');
request()->server->set('HTTPS', 'on'); request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off');
request()->headers->set('X-Forwarded-Proto', 'https');
} }
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore { $this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Widgets\ActiveUserOverview; use App\Filament\Widgets\ActiveUserOverview;
use App\Filament\Widgets\ServerOverview;
use App\Filament\Widgets\TimeEntriesCreated; use App\Filament\Widgets\TimeEntriesCreated;
use App\Filament\Widgets\TimeEntriesImported; use App\Filament\Widgets\TimeEntriesImported;
use App\Filament\Widgets\UserRegistrations; use App\Filament\Widgets\UserRegistrations;
@@ -45,13 +44,11 @@ class AdminPanelProvider extends PanelProvider
]) ])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([ ->widgets([
ServerOverview::class,
ActiveUserOverview::class, ActiveUserOverview::class,
UserRegistrations::class, UserRegistrations::class,
TimeEntriesCreated::class, TimeEntriesCreated::class,
TimeEntriesImported::class, TimeEntriesImported::class,
]) ])
->viteTheme('resources/css/filament/admin/theme.css')
->plugins([ ->plugins([
EnvironmentIndicatorPlugin::make() EnvironmentIndicatorPlugin::make()
->color(fn () => match (App::environment()) { ->color(fn () => match (App::environment()) {

View File

@@ -37,9 +37,9 @@ class RouteServiceProvider extends ServiceProvider
: Limit::perMinute(60)->by($request->ip()); : Limit::perMinute(60)->by($request->ip());
}); });
$this->routes(function (): void { $this->routes(function () {
Route::middleware('health-check') Route::middleware('health-check')
->group(function (): void { ->group(function () {
Route::get('health-check/up', [HealthCheckController::class, 'up']); Route::get('health-check/up', [HealthCheckController::class, 'up']);
Route::get('health-check/debug', [HealthCheckController::class, 'debug']); Route::get('health-check/debug', [HealthCheckController::class, 'debug']);
}); });

View File

@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Models\Audit;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Exception;
use Illuminate\Support\Facades\Http;
use Log;
class ApiService
{
private const string API_URL = 'https://app.solidtime.io/api/v1';
public function checkForUpdate(): ?string
{
try {
$response = Http::asJson()
->timeout(3)
->connectTimeout(2)
->post(self::API_URL.'/ping/version', [
'version' => config('app.version'),
'build' => config('app.build'),
'url' => config('app.url'),
]);
if ($response->status() === 200 && isset($response->json()['version']) && is_string($response->json()['version'])) {
return $response->json()['version'];
} else {
Log::warning('Failed to check for update', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
} catch (\Throwable $e) {
Log::warning('Failed to check for update', [
'message' => $e->getMessage(),
]);
return null;
}
}
public function telemetry(): bool
{
try {
$response = Http::asJson()
->timeout(3)
->connectTimeout(2)
->post(self::API_URL.'/ping/telemetry', [
'version' => config('app.version'),
'build' => config('app.build'),
'url' => config('app.url'),
// telemetry data
'user_count' => User::count(),
'organization_count' => Organization::count(),
'audit_count' => Audit::count(),
'project_count' => Project::count(),
'project_member_count' => ProjectMember::count(),
'client_count' => Client::count(),
'task_count' => Task::count(),
'time_entry_count' => TimeEntry::count(),
]);
if ($response->status() === 200) {
return true;
} else {
Log::warning('Failed send telemetry data', [
'status' => $response->status(),
'body' => $response->body(),
]);
return false;
}
} catch (Exception $e) {
Log::warning('Failed send telemetry data', [
'message' => $e->getMessage(),
]);
return false;
}
}
}

View File

@@ -28,9 +28,9 @@ class BillableRateService
->where('billable', '=', true) ->where('billable', '=', true)
->where('organization_id', '=', $project->organization_id) ->where('organization_id', '=', $project->organization_id)
->whereBelongsTo($project, 'project') ->whereBelongsTo($project, 'project')
->whereDoesntHave('member', function (Builder $query) use ($project): void { ->whereDoesntHave('member', function (Builder $query) use ($project) {
/** @var Builder<Member> $query */ /** @var Builder<Member> $query */
$query->whereHas('projectMembers', function (Builder $query) use ($project): void { $query->whereHas('projectMembers', function (Builder $query) use ($project) {
/** @var Builder<ProjectMember> $query */ /** @var Builder<ProjectMember> $query */
$query->whereBelongsTo($project, 'project') $query->whereBelongsTo($project, 'project')
->whereNotNull('billable_rate'); ->whereNotNull('billable_rate');
@@ -62,7 +62,7 @@ class BillableRateService
TimeEntry::query() TimeEntry::query()
->where('billable', '=', true) ->where('billable', '=', true)
->where('organization_id', '=', $organization->getKey()) ->where('organization_id', '=', $organization->getKey())
->whereDoesntHave('member', function (Builder $builder): void { ->whereDoesntHave('member', function (Builder $builder) {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
$builder->whereNotNull('billable_rate'); $builder->whereNotNull('billable_rate');
}) })

View File

@@ -22,7 +22,7 @@ class BillingContract
*/ */
public function hasSubscription(Organization $organization): bool public function hasSubscription(Organization $organization): bool
{ {
return true; return false;
} }
/** /**

View File

@@ -47,24 +47,22 @@ class DashboardService
{ {
$result = []; $result = [];
$windowSize = 24 / $windows; $windowSize = 24 / $windows;
$end = Carbon::now($timeZone)->startOfDay()->addDay()->subHours(3)->utc()->toDateTimeString(); $end = Carbon::now($timeZone)->endOfDay()->subHours(3)->utc()->toDateTimeString();
$start = Carbon::now($timeZone)->subDays($days)->startOfDay()->utc()->toDateTimeString(); $start = Carbon::now($timeZone)->subDays($days)->startOfDay()->utc()->toDateTimeString();
$date = Carbon::now($timeZone)->startOfDay(); $date = Carbon::now($timeZone)->startOfDay();
$dateUtc = Carbon::now($timeZone)->startOfDay()->utc();
for ($i = 0; $i < $days; $i++) { for ($i = 0; $i < $days; $i++) {
$dateString = $date->format('Y-m-d'); $dateString = $date->format('Y-m-d');
$tempDate = $dateUtc->copy(); $tempDate = $date->copy();
$start = $tempDate->copy()->utc()->toDateTimeString(); $start = $tempDate->copy()->utc()->toDateTimeString();
$tempWindows = []; $tempWindows = [];
for ($j = 0; $j < $windows; $j++) { for ($j = 0; $j < $windows; $j++) {
$tempWindow = $tempDate->toDateTimeString(); $tempWindow = $tempDate->utc()->toDateTimeString();
$tempWindows[] = $tempWindow; $tempWindows[] = $tempWindow;
$tempDate->addHours($windowSize); $tempDate->addHours($windowSize);
} }
$result[$dateString] = $tempWindows; $result[$dateString] = $tempWindows;
$date->subDay(); $date->subDay();
$dateUtc->subDay();
} }
return [ return [

View File

@@ -35,7 +35,7 @@ class DeletionService
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
{ {
if ($inTransaction) { if ($inTransaction) {
DB::transaction(function () use ($organization): void { DB::transaction(function () use ($organization) {
$this->deleteOrganization($organization, false); $this->deleteOrganization($organization, false);
}); });
@@ -123,7 +123,7 @@ class DeletionService
public function deleteUser(User $user, bool $inTransaction = true): void public function deleteUser(User $user, bool $inTransaction = true): void
{ {
if ($inTransaction) { if ($inTransaction) {
DB::transaction(function () use ($user): void { DB::transaction(function () use ($user) {
$this->deleteUser($user, false); $this->deleteUser($user, false);
}); });
@@ -144,7 +144,6 @@ class DeletionService
->get(); ->get();
foreach ($members as $member) { foreach ($members as $member) {
/** @var Member $member */
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) { if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
} }
@@ -155,13 +154,10 @@ class DeletionService
if ($member->role === Role::Owner->value) { if ($member->role === Role::Owner->value) {
$this->deleteOrganization($member->organization, false, $user); $this->deleteOrganization($member->organization, false, $user);
} else { } else {
$this->memberService->makeMemberToPlaceholder($member, false); $this->memberService->makeMemberToPlaceholder($member);
} }
} }
$user->accessTokens()->delete();
$user->authCodes()->delete();
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last // Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
$user->deleteProfilePhoto(); $user->deleteProfilePhoto();

View File

@@ -47,9 +47,6 @@ class ExportService
// Organizations // Organizations
try { try {
$writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'name', 'name',
@@ -69,9 +66,6 @@ class ExportService
// Organization invitations // Organization invitations
$writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'email', 'email',
@@ -97,9 +91,6 @@ class ExportService
// Time entries // Time entries
$writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'description', 'description',
@@ -148,9 +139,6 @@ class ExportService
// Clients // Clients
$writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'name', 'name',
@@ -176,9 +164,6 @@ class ExportService
// Projects // Projects
$writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'name', 'name',
@@ -214,9 +199,6 @@ class ExportService
// Project members // Project members
$writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'billable_rate', 'billable_rate',
@@ -244,9 +226,6 @@ class ExportService
// Members // Members
$writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'user_id', 'user_id',
@@ -281,9 +260,6 @@ class ExportService
// Tasks // Tasks
$writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'name', 'name',
@@ -311,9 +287,6 @@ class ExportService
// Tags // Tags
$writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'name', 'name',

View File

@@ -31,7 +31,7 @@ class ImportService
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1); $lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
if ($lock->get()) { if ($lock->get()) {
DB::transaction(function () use (&$importer, &$data, &$timezone): void { DB::transaction(function () use (&$importer, &$data, &$timezone) {
$importer->importData($data, $timezone); $importer->importData($data, $timezone);
}); });
$lock->release(); $lock->release();

View File

@@ -47,8 +47,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader = Reader::createFromString($data); $reader = Reader::createFromString($data);
$reader->setHeaderOffset(0); $reader->setHeaderOffset(0);
$reader->setDelimiter(','); $reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$records = $reader->getRecords(); $records = $reader->getRecords();

View File

@@ -112,9 +112,8 @@ abstract class DefaultImporter implements ImporterContract
'billable_rate' => [ 'billable_rate' => [
'nullable', 'nullable',
'integer', 'integer',
'max:2147483647',
], ],
], beforeSave: function (Project $project): void { ], beforeSave: function (Project $project) {
if ($project->billable_rate === 0) { if ($project->billable_rate === 0) {
$project->billable_rate = null; $project->billable_rate = null;
} }
@@ -126,9 +125,8 @@ abstract class DefaultImporter implements ImporterContract
'billable_rate' => [ 'billable_rate' => [
'nullable', 'nullable',
'integer', 'integer',
'max:2147483647',
], ],
], beforeSave: function (ProjectMember $projectMember): void { ], beforeSave: function (ProjectMember $projectMember) {
if ($projectMember->billable_rate === 0) { if ($projectMember->billable_rate === 0) {
$projectMember->billable_rate = null; $projectMember->billable_rate = null;
} }

View File

@@ -60,8 +60,6 @@ class SolidtimeImporter extends DefaultImporter
$clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv')); $clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv'));
$clientsReader->setHeaderOffset(0); $clientsReader->setHeaderOffset(0);
$clientsReader->setDelimiter(','); $clientsReader->setDelimiter(',');
$clientsReader->setEnclosure('"');
$clientsReader->setEscape('');
if (! file_exists($temporaryDirectory->path('members.csv'))) { if (! file_exists($temporaryDirectory->path('members.csv'))) {
throw new ImportException('File "members.csv" missing in ZIP'); throw new ImportException('File "members.csv" missing in ZIP');
@@ -69,8 +67,6 @@ class SolidtimeImporter extends DefaultImporter
$membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv')); $membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv'));
$membersReader->setHeaderOffset(0); $membersReader->setHeaderOffset(0);
$membersReader->setDelimiter(','); $membersReader->setDelimiter(',');
$membersReader->setEnclosure('"');
$membersReader->setEscape('');
if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) { if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) {
throw new ImportException('File "organization_invitations.csv" missing in ZIP'); throw new ImportException('File "organization_invitations.csv" missing in ZIP');
@@ -78,8 +74,6 @@ class SolidtimeImporter extends DefaultImporter
$organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv')); $organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv'));
$organizationInvitationsReader->setHeaderOffset(0); $organizationInvitationsReader->setHeaderOffset(0);
$organizationInvitationsReader->setDelimiter(','); $organizationInvitationsReader->setDelimiter(',');
$organizationInvitationsReader->setEnclosure('"');
$organizationInvitationsReader->setEscape('');
if (! file_exists($temporaryDirectory->path('project_members.csv'))) { if (! file_exists($temporaryDirectory->path('project_members.csv'))) {
throw new ImportException('File "project_members.csv" missing in ZIP'); throw new ImportException('File "project_members.csv" missing in ZIP');
@@ -87,8 +81,6 @@ class SolidtimeImporter extends DefaultImporter
$projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv')); $projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv'));
$projectMembersReader->setHeaderOffset(0); $projectMembersReader->setHeaderOffset(0);
$projectMembersReader->setDelimiter(','); $projectMembersReader->setDelimiter(',');
$projectMembersReader->setEnclosure('"');
$projectMembersReader->setEscape('');
if (! file_exists($temporaryDirectory->path('projects.csv'))) { if (! file_exists($temporaryDirectory->path('projects.csv'))) {
throw new ImportException('File "projects.csv" missing in ZIP'); throw new ImportException('File "projects.csv" missing in ZIP');
@@ -96,8 +88,6 @@ class SolidtimeImporter extends DefaultImporter
$projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv')); $projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv'));
$projectsReader->setHeaderOffset(0); $projectsReader->setHeaderOffset(0);
$projectsReader->setDelimiter(','); $projectsReader->setDelimiter(',');
$projectsReader->setEnclosure('"');
$projectsReader->setEscape('');
if (! file_exists($temporaryDirectory->path('tags.csv'))) { if (! file_exists($temporaryDirectory->path('tags.csv'))) {
throw new ImportException('File "tags.csv" missing in ZIP'); throw new ImportException('File "tags.csv" missing in ZIP');
@@ -105,8 +95,6 @@ class SolidtimeImporter extends DefaultImporter
$tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv')); $tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv'));
$tagsReader->setHeaderOffset(0); $tagsReader->setHeaderOffset(0);
$tagsReader->setDelimiter(','); $tagsReader->setDelimiter(',');
$tagsReader->setEnclosure('"');
$tagsReader->setEscape('');
if (! file_exists($temporaryDirectory->path('tasks.csv'))) { if (! file_exists($temporaryDirectory->path('tasks.csv'))) {
throw new ImportException('File "tasks.csv" missing in ZIP'); throw new ImportException('File "tasks.csv" missing in ZIP');
@@ -114,8 +102,6 @@ class SolidtimeImporter extends DefaultImporter
$tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv')); $tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv'));
$tasksReader->setHeaderOffset(0); $tasksReader->setHeaderOffset(0);
$tasksReader->setDelimiter(','); $tasksReader->setDelimiter(',');
$tasksReader->setEnclosure('"');
$tasksReader->setEscape('');
if (! file_exists($temporaryDirectory->path('time_entries.csv'))) { if (! file_exists($temporaryDirectory->path('time_entries.csv'))) {
throw new ImportException('File "time_entries.csv" missing in ZIP'); throw new ImportException('File "time_entries.csv" missing in ZIP');
@@ -123,8 +109,6 @@ class SolidtimeImporter extends DefaultImporter
$timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv')); $timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv'));
$timeEntriesReader->setHeaderOffset(0); $timeEntriesReader->setHeaderOffset(0);
$timeEntriesReader->setDelimiter(','); $timeEntriesReader->setDelimiter(',');
$timeEntriesReader->setEnclosure('"');
$timeEntriesReader->setEscape('');
foreach ($clientsReader as $client) { foreach ($clientsReader as $client) {
$this->clientImportHelper->getKey([ $this->clientImportHelper->getKey([

View File

@@ -47,8 +47,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
$reader = Reader::createFromString($data); $reader = Reader::createFromString($data);
$reader->setHeaderOffset(0); $reader->setHeaderOffset(0);
$reader->setDelimiter(','); $reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$records = $reader->getRecords(); $records = $reader->getRecords();

View File

@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Carbon\CarbonInterval;
class IntervalService
{
public function format(CarbonInterval $interval): string
{
$interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S');
}
}

View File

@@ -44,7 +44,7 @@ class MemberService
} }
} }
public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void public function makeMemberToPlaceholder(Member $member): void
{ {
$user = $member->user; $user = $member->user;
$placeholderUser = $user->replicate(); $placeholderUser = $user->replicate();
@@ -56,8 +56,6 @@ class MemberService
$member->save(); $member->save();
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member); $this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
if ($makeSureUserHasAtLeastOneOrganization) { $this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
}
} }
} }

View File

@@ -1,118 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\File;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use League\Csv\Writer;
use Spatie\TemporaryDirectory\TemporaryDirectory;
/**
* @template T of Model
*/
abstract class CsvExport
{
private string $disk;
private string $filename;
private int $chunk;
/**
* @var string[]
*/
public const array HEADER = [];
/**
* @var Builder<T>
*/
private Builder $builder;
private string $folderPath;
protected const string CARBON_FORMAT = 'Y-m-d\TH:i:sP';
/**
* @param Builder<T> $builder
*/
public function __construct(string $disk, string $folderPath, string $filename, Builder $builder, int $chunk)
{
$this->disk = $disk;
$this->filename = $filename;
$this->chunk = $chunk;
$this->builder = $builder;
$this->folderPath = $folderPath;
}
/**
* @param T $model
* @return array<string, string|float|Carbon|null>
*/
abstract public function mapRow(Model $model): array;
/**
* @throws \League\Csv\CannotInsertRecord
* @throws \League\Csv\Exception
* @throws \League\Csv\UnavailableStream
*/
public function export(): void
{
$tempDirectory = TemporaryDirectory::make();
$writer = Writer::createFromPath($tempDirectory->path($this->filename), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne(static::HEADER);
$this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void {
foreach ($models as $model) {
$data = $this->mapRow($model);
$row = $this->convertRow($data);
$this->validateRow($row);
$writer->insertOne(array_values($row));
}
});
Storage::disk($this->disk)->putFileAs($this->folderPath, new File($tempDirectory->path($this->filename)), $this->filename);
$tempDirectory->delete();
}
/**
* @param array<string, string|float|Carbon|null> $data
* @return array<string, string>
*/
private function convertRow(array $data): array
{
$convertedRow = [];
foreach ($data as $key => $value) {
if ($value instanceof Carbon) {
$convertedRow[$key] = $value->format(static::CARBON_FORMAT);
} elseif (is_float($value)) {
$convertedRow[$key] = (string) $value;
} elseif ($value === null) {
$convertedRow[$key] = '';
} else {
$convertedRow[$key] = $value;
}
}
return $convertedRow;
}
/**
* @param array<string, string> $row
*/
private function validateRow(array $row): void
{
if (array_keys($row) !== static::HEADER) {
throw new \LogicException('Invalid row');
}
}
}

View File

@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Models\TimeEntry;
use App\Service\IntervalService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @extends CsvExport<TimeEntry>
*/
class TimeEntriesDetailedCsvExport extends CsvExport
{
public const array HEADER = [
'Description',
'Task',
'Project',
'Client',
'User',
'Start',
'End',
'Duration',
'Duration (decimal)',
'Billable',
'Tags',
];
protected const string CARBON_FORMAT = 'Y-m-d H:i:s';
private string $timezone;
public function __construct(string $disk, string $folderPath, string $filename, Builder $builder, int $chunk, string $timezone)
{
parent::__construct($disk, $folderPath, $filename, $builder, $chunk);
$this->timezone = $timezone;
}
/**
* @param TimeEntry $model
*/
public function mapRow(Model $model): array
{
$interval = app(IntervalService::class);
$duration = $model->getDuration();
return [
'Description' => $model->description,
'Task' => $model->task?->name,
'Project' => $model->project?->name,
'Client' => $model->client?->name,
'User' => $model->user->name,
'Start' => $model->start->timezone($this->timezone),
'End' => $model->end->timezone($this->timezone),
'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null,
'Duration (decimal)' => $duration?->totalHours,
'Billable' => $model->billable ? 'Yes' : 'No',
'Tags' => $model->tagsRelation->pluck('name')->implode(', '),
];
}
}

View File

@@ -1,151 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Models\TimeEntry;
use App\Service\IntervalService;
use Illuminate\Database\Eloquent\Builder;
use LogicException;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Style\Style;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* @implements WithMapping<TimeEntry>
*/
class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithHeadings, WithMapping, WithStyles
{
use Exportable;
/**
* @var Builder<TimeEntry>
*/
private Builder $builder;
private ExportFormat $exportFormat;
private string $timezone;
/**
* @param Builder<TimeEntry> $builder
*/
public function __construct(Builder $builder, ExportFormat $exportFormat, string $timezone)
{
$this->builder = $builder;
$this->exportFormat = $exportFormat;
$this->timezone = $timezone;
}
/**
* @return Builder<TimeEntry>
*/
public function query(): Builder
{
return $this->builder;
}
/**
* @return array<string, string>
*/
public function columnFormats(): array
{
if ($this->exportFormat === ExportFormat::XLSX) {
return [
'F' => 'yyyy-mm-dd hh:mm:ss',
'G' => 'yyyy-mm-dd hh:mm:ss',
'I' => NumberFormat::FORMAT_NUMBER_00,
];
} elseif ($this->exportFormat === ExportFormat::ODS) {
return [
'I' => NumberFormat::FORMAT_NUMBER_00,
];
} else {
throw new LogicException('Unsupported export format.');
}
}
/**
* @return array<int|string, array<string, array<string, bool>>>
*/
public function styles(Worksheet $sheet): array
{
return [
// Style the first row as bold text.
1 => ['font' => ['bold' => true]],
];
}
/**
* @return string[]
*/
public function headings(): array
{
return [
'Description',
'Task',
'Project',
'Client',
'User',
'Start',
'End',
'Duration',
'Duration (decimal)',
'Billable',
'Tags',
];
}
/**
* @param TimeEntry $model
* @return array<int, string|float|null>
*/
public function map($model): array
{
$interval = app(IntervalService::class);
$duration = $model->getDuration();
if ($this->exportFormat === ExportFormat::XLSX) {
return [
$model->description,
$model->task?->name,
$model->project?->name,
$model->client?->name,
$model->user->name,
Date::dateTimeToExcel($model->start->timezone($this->timezone)),
$model->end !== null ? Date::dateTimeToExcel($model->end->timezone($this->timezone)) : null,
$duration !== null ? $interval->format($duration) : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} elseif ($this->exportFormat === ExportFormat::ODS) {
return [
$model->description,
$model->task?->name,
$model->project?->name,
$model->client?->name,
$model->user->name,
$model->start->timezone($this->timezone)->format('Y-m-d H:i:s'),
$model->end?->timezone($this->timezone)?->format('Y-m-d H:i:s'),
$duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} else {
throw new LogicException('Unsupported export format.');
}
}
}

View File

@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use Illuminate\View\View;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromView;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithCustomCsvSettings;
class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsvSettings
{
use Exportable;
/**
* @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* }
*/
private array $data;
private ExportFormat $exportFormat;
private string $currency;
private TimeEntryAggregationType $group;
private TimeEntryAggregationType $subGroup;
/**
* @param array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* } $data
*/
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
{
$this->data = $data;
$this->exportFormat = $exportFormat;
$this->currency = $currency;
$this->group = $group;
$this->subGroup = $subGroup;
}
public function view(): View
{
return view('reports.time-entry-aggregate-index-excel', [
'data' => $this->data,
'currency' => $this->currency,
'group' => $this->group,
'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat,
]);
}
/**
* @return array<string, string>
*/
public function getCsvSettings(): array
{
return [
'delimiter' => ',',
'enclosure' => '"',
'escape_character' => '',
];
}
}

View File

@@ -7,11 +7,7 @@ namespace App\Service;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User;
use Carbon\CarbonTimeZone; use Carbon\CarbonTimeZone;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -139,118 +135,6 @@ class TimeEntryAggregationService
]; ];
} }
/**
* @param Builder<TimeEntry> $timeEntriesQuery
* @return array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* }
*/
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
{
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end);
$keysGroup1 = [];
$keysGroup2 = [];
if ($aggregatedTimeEntries['grouped_data'] !== null) {
foreach ($aggregatedTimeEntries['grouped_data'] as $group1) {
$keysGroup1[] = $group1['key'];
if ($group1['grouped_data'] !== null) {
foreach ($group1['grouped_data'] as $group2) {
$keysGroup2[] = $group2['key'];
}
}
}
}
$descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptionMap($keysGroup1, $group1Type) : [];
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : [];
if ($aggregatedTimeEntries['grouped_data'] !== null) {
foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) {
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null;
if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) {
foreach ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] as $keyGroup2 => $group2) {
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']] ?? null) : null;
}
}
}
}
/**
* @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* } $aggregatedTimeEntries
*/
return $aggregatedTimeEntries;
}
/**
* @param array<int, string> $keys
* @return array<string, string>
*/
private function loadDescriptionMap(array $keys, TimeEntryAggregationType $type): array
{
if ($type === TimeEntryAggregationType::Client) {
return Client::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
} elseif ($type === TimeEntryAggregationType::User) {
return User::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
} elseif ($type === TimeEntryAggregationType::Project) {
return Project::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
} elseif ($type === TimeEntryAggregationType::Task) {
return Task::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
} else {
return [];
}
}
/** /**
* @param array<array{ * @param array<array{
* key: string|null, * key: string|null,

View File

@@ -133,11 +133,7 @@ class TimeEntryFilter
if ($tagIds === null) { if ($tagIds === null) {
return $this; return $this;
} }
$this->builder->where(function (Builder $builder) use ($tagIds): void { $this->builder->whereJsonContains('tags', $tagIds);
foreach ($tagIds as $tagId) {
$builder->orWhereJsonContains('tags', $tagId);
}
});
return $this; return $this;
} }

View File

@@ -2,17 +2,16 @@
"name": "solidtime-io/solidtime", "name": "solidtime-io/solidtime",
"type": "project", "type": "project",
"description": "An open-source time-tracking app", "description": "An open-source time-tracking app",
"version": "0.0.1",
"keywords": [], "keywords": [],
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"require": { "require": {
"php": "8.3.*", "php": "8.3.*",
"ext-zip": "*", "ext-zip": "*",
"brick/money": "^0.9.0", "brick/money": "^0.9.0",
"datomatic/laravel-enum-helper": "^1.1",
"dedoc/scramble": "dev-main", "dedoc/scramble": "dev-main",
"filament/filament": "^3.2", "filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.2.0", "flowframe/laravel-trend": "^0.2.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^1.0", "inertiajs/inertia-laravel": "^1.0",
"korridor/laravel-computed-attributes": "^3.1", "korridor/laravel-computed-attributes": "^3.1",
@@ -22,18 +21,15 @@
"laravel/octane": "^2.3", "laravel/octane": "^2.3",
"laravel/passport": "^12.0", "laravel/passport": "^12.0",
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
"league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"maatwebsite/excel": "^3.1",
"novadaemon/filament-pretty-json": "^2.2", "novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^11.0.11", "nwidart/laravel-modules": "^11.0.11",
"owen-it/laravel-auditing": "^13.6", "owen-it/laravel-auditing": "^13.6",
"pxlrbt/filament-environment-indicator": "^2.0", "pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2", "spatie/temporary-directory": "^2.2",
"staudenmeir/eloquent-json-relations": "^1.1",
"stechstudio/filament-impersonate": "^3.8", "stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0", "tightenco/ziggy": "^2.1.0",
"tpetry/laravel-postgresql-enhanced": "^2.0.0", "tpetry/laravel-postgresql-enhanced": "^1.0.0",
"wikimedia/composer-merge-plugin": "^2.1.0" "wikimedia/composer-merge-plugin": "^2.1.0"
}, },
"require-dev": { "require-dev": {

2119
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,11 +18,7 @@ return [
| |
*/ */
'name' => 'solidtime', 'name' => env('APP_NAME', 'solidtime'),
'version' => env('APP_VERSION'),
'build' => env('APP_BUILD'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -1,382 +0,0 @@
<?php
declare(strict_types=1);
use Maatwebsite\Excel\Excel;
use PhpOffice\PhpSpreadsheet\Reader\Csv;
return [
'exports' => [
/*
|--------------------------------------------------------------------------
| Chunk size
|--------------------------------------------------------------------------
|
| When using FromQuery, the query is automatically chunked.
| Here you can specify how big the chunk should be.
|
*/
'chunk_size' => 1000,
/*
|--------------------------------------------------------------------------
| Pre-calculate formulas during export
|--------------------------------------------------------------------------
*/
'pre_calculate_formulas' => false,
/*
|--------------------------------------------------------------------------
| Enable strict null comparison
|--------------------------------------------------------------------------
|
| When enabling strict null comparison empty cells ('') will
| be added to the sheet.
*/
'strict_null_comparison' => false,
/*
|--------------------------------------------------------------------------
| CSV Settings
|--------------------------------------------------------------------------
|
| Configure e.g. delimiter, enclosure and line ending for CSV exports.
|
*/
'csv' => [
'delimiter' => ',',
'enclosure' => '"',
'line_ending' => PHP_EOL,
'use_bom' => false,
'include_separator_line' => false,
'excel_compatibility' => false,
'output_encoding' => '',
'test_auto_detect' => true,
],
/*
|--------------------------------------------------------------------------
| Worksheet properties
|--------------------------------------------------------------------------
|
| Configure e.g. default title, creator, subject,...
|
*/
'properties' => [
'creator' => '',
'lastModifiedBy' => '',
'title' => '',
'description' => '',
'subject' => '',
'keywords' => '',
'category' => '',
'manager' => '',
'company' => '',
],
],
'imports' => [
/*
|--------------------------------------------------------------------------
| Read Only
|--------------------------------------------------------------------------
|
| When dealing with imports, you might only be interested in the
| data that the sheet exists. By default we ignore all styles,
| however if you want to do some logic based on style data
| you can enable it by setting read_only to false.
|
*/
'read_only' => true,
/*
|--------------------------------------------------------------------------
| Ignore Empty
|--------------------------------------------------------------------------
|
| When dealing with imports, you might be interested in ignoring
| rows that have null values or empty strings. By default rows
| containing empty strings or empty values are not ignored but can be
| ignored by enabling the setting ignore_empty to true.
|
*/
'ignore_empty' => false,
/*
|--------------------------------------------------------------------------
| Heading Row Formatter
|--------------------------------------------------------------------------
|
| Configure the heading row formatter.
| Available options: none|slug|custom
|
*/
'heading_row' => [
'formatter' => 'slug',
],
/*
|--------------------------------------------------------------------------
| CSV Settings
|--------------------------------------------------------------------------
|
| Configure e.g. delimiter, enclosure and line ending for CSV imports.
|
*/
'csv' => [
'delimiter' => null,
'enclosure' => '"',
'escape_character' => '\\',
'contiguous' => false,
'input_encoding' => Csv::GUESS_ENCODING,
],
/*
|--------------------------------------------------------------------------
| Worksheet properties
|--------------------------------------------------------------------------
|
| Configure e.g. default title, creator, subject,...
|
*/
'properties' => [
'creator' => '',
'lastModifiedBy' => '',
'title' => '',
'description' => '',
'subject' => '',
'keywords' => '',
'category' => '',
'manager' => '',
'company' => '',
],
/*
|--------------------------------------------------------------------------
| Cell Middleware
|--------------------------------------------------------------------------
|
| Configure middleware that is executed on getting a cell value
|
*/
'cells' => [
'middleware' => [
//\Maatwebsite\Excel\Middleware\TrimCellValue::class,
//\Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class,
],
],
],
/*
|--------------------------------------------------------------------------
| Extension detector
|--------------------------------------------------------------------------
|
| Configure here which writer/reader type should be used when the package
| needs to guess the correct type based on the extension alone.
|
*/
'extension_detector' => [
'xlsx' => Excel::XLSX,
'xlsm' => Excel::XLSX,
'xltx' => Excel::XLSX,
'xltm' => Excel::XLSX,
'xls' => Excel::XLS,
'xlt' => Excel::XLS,
'ods' => Excel::ODS,
'ots' => Excel::ODS,
'slk' => Excel::SLK,
'xml' => Excel::XML,
'gnumeric' => Excel::GNUMERIC,
'htm' => Excel::HTML,
'html' => Excel::HTML,
'csv' => Excel::CSV,
'tsv' => Excel::TSV,
/*
|--------------------------------------------------------------------------
| PDF Extension
|--------------------------------------------------------------------------
|
| Configure here which Pdf driver should be used by default.
| Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF
|
*/
'pdf' => Excel::DOMPDF,
],
/*
|--------------------------------------------------------------------------
| Value Binder
|--------------------------------------------------------------------------
|
| PhpSpreadsheet offers a way to hook into the process of a value being
| written to a cell. In there some assumptions are made on how the
| value should be formatted. If you want to change those defaults,
| you can implement your own default value binder.
|
| Possible value binders:
|
| [x] Maatwebsite\Excel\DefaultValueBinder::class
| [x] PhpOffice\PhpSpreadsheet\Cell\StringValueBinder::class
| [x] PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder::class
|
*/
'value_binder' => [
'default' => Maatwebsite\Excel\DefaultValueBinder::class,
],
'cache' => [
/*
|--------------------------------------------------------------------------
| Default cell caching driver
|--------------------------------------------------------------------------
|
| By default PhpSpreadsheet keeps all cell values in memory, however when
| dealing with large files, this might result into memory issues. If you
| want to mitigate that, you can configure a cell caching driver here.
| When using the illuminate driver, it will store each value in the
| cache store. This can slow down the process, because it needs to
| store each value. You can use the "batch" store if you want to
| only persist to the store when the memory limit is reached.
|
| Drivers: memory|illuminate|batch
|
*/
'driver' => 'memory',
/*
|--------------------------------------------------------------------------
| Batch memory caching
|--------------------------------------------------------------------------
|
| When dealing with the "batch" caching driver, it will only
| persist to the store when the memory limit is reached.
| Here you can tweak the memory limit to your liking.
|
*/
'batch' => [
'memory_limit' => 60000,
],
/*
|--------------------------------------------------------------------------
| Illuminate cache
|--------------------------------------------------------------------------
|
| When using the "illuminate" caching driver, it will automatically use
| your default cache store. However if you prefer to have the cell
| cache on a separate store, you can configure the store name here.
| You can use any store defined in your cache config. When leaving
| at "null" it will use the default store.
|
*/
'illuminate' => [
'store' => null,
],
/*
|--------------------------------------------------------------------------
| Cache Time-to-live (TTL)
|--------------------------------------------------------------------------
|
| The TTL of items written to cache. If you want to keep the items cached
| indefinitely, set this to null. Otherwise, set a number of seconds,
| a \DateInterval, or a callable.
|
| Allowable types: callable|\DateInterval|int|null
|
*/
'default_ttl' => 10800,
],
/*
|--------------------------------------------------------------------------
| Transaction Handler
|--------------------------------------------------------------------------
|
| By default the import is wrapped in a transaction. This is useful
| for when an import may fail and you want to retry it. With the
| transactions, the previous import gets rolled-back.
|
| You can disable the transaction handler by setting this to null.
| Or you can choose a custom made transaction handler here.
|
| Supported handlers: null|db
|
*/
'transactions' => [
'handler' => 'db',
'db' => [
'connection' => null,
],
],
'temporary_files' => [
/*
|--------------------------------------------------------------------------
| Local Temporary Path
|--------------------------------------------------------------------------
|
| When exporting and importing files, we use a temporary file, before
| storing reading or downloading. Here you can customize that path.
| permissions is an array with the permission flags for the directory (dir)
| and the create file (file).
|
*/
'local_path' => storage_path('framework/cache/laravel-excel'),
/*
|--------------------------------------------------------------------------
| Local Temporary Path Permissions
|--------------------------------------------------------------------------
|
| Permissions is an array with the permission flags for the directory (dir)
| and the create file (file).
| If omitted the default permissions of the filesystem will be used.
|
*/
'local_permissions' => [
// 'dir' => 0755,
// 'file' => 0644,
],
/*
|--------------------------------------------------------------------------
| Remote Temporary Disk
|--------------------------------------------------------------------------
|
| When dealing with a multi server setup with queues in which you
| cannot rely on having a shared local temporary path, you might
| want to store the temporary file on a shared disk. During the
| queue executing, we'll retrieve the temporary file from that
| location instead. When left to null, it will always use
| the local path. This setting only has effect when using
| in conjunction with queued imports and exports.
|
*/
'remote_disk' => null,
'remote_prefix' => null,
/*
|--------------------------------------------------------------------------
| Force Resync
|--------------------------------------------------------------------------
|
| When dealing with a multi server setup as above, it's possible
| for the clean up that occurs after entire queue has been run to only
| cleanup the server that the last AfterImportJob runs on. The rest of the server
| would still have the local temporary file stored on it. In this case your
| local storage limits can be exceeded and future imports won't be processed.
| To mitigate this you can set this config value to be true, so that after every
| queued chunk is processed the local temporary file is deleted on the server that
| processed it.
|
*/
'force_resync_remote' => null,
],
];

View File

@@ -6,7 +6,5 @@ return [
'tasks' => [ 'tasks' => [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true), 'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
], ],
]; ];

View File

@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
return [
'gotenberg' => [
'url' => env('GOTENBERG_URL'),
'basic_auth_username' => env('GOTENBERG_BASIC_AUTH_USERNAME'),
'basic_auth_password' => env('GOTENBERG_BASIC_AUTH_PASSWORD'),
],
];

View File

@@ -85,7 +85,7 @@ class MemberFactory extends Factory
public function attachToOrganization(Organization $organization, array $pivot = []): static public function attachToOrganization(Organization $organization, array $pivot = []): static
{ {
return $this->afterCreating(function (User $user) use ($organization, $pivot): void { return $this->afterCreating(function (User $user) use ($organization, $pivot) {
$user->organizations()->attach($organization, $pivot); $user->organizations()->attach($organization, $pivot);
}); });
} }

View File

@@ -26,7 +26,6 @@ class OrganizationFactory extends Factory
'billable_rate' => null, 'billable_rate' => null,
'user_id' => User::factory(), 'user_id' => User::factory(),
'personal_team' => true, 'personal_team' => true,
'employees_can_see_billable_rates' => false,
]; ];
} }

View File

@@ -11,7 +11,6 @@ use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Service\ColorService; use App\Service\ColorService;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/** /**
* @extends Factory<Project> * @extends Factory<Project>
@@ -34,34 +33,15 @@ class ProjectFactory extends Factory
'archived_at' => null, 'archived_at' => null,
'client_id' => null, 'client_id' => null,
'organization_id' => Organization::factory(), 'organization_id' => Organization::factory(),
'estimated_time' => null,
]; ];
} }
public function withEstimatedTime(): self public function billable(): self
{ {
return $this->state(function (array $attributes): array { return $this->state(function (array $attributes): array {
return [
'estimated_time' => $this->faker->randomNumber(3),
];
});
}
public function billable(?int $billableRate = null): self
{
return $this->state(function (array $attributes) use ($billableRate): array {
return [ return [
'is_billable' => true, 'is_billable' => true,
'billable_rate' => $billableRate === null ? $this->faker->numberBetween(50, 1000) * 100 : $billableRate, 'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
];
});
}
public function createdAt(Carbon $createdAt): self
{
return $this->state(function (array $attributes) use ($createdAt): array {
return [
'created_at' => $createdAt,
]; ];
}); });
} }

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