Compare commits

..

68 Commits

Author SHA1 Message Date
Constantin Graf
42da2c3397 Set timeout for all GitHub actions 2024-07-01 12:09:30 +02:00
Constantin Graf
62ac23cb1a Fixed tests after adding schema dumps for test database 2024-07-01 12:08:53 +02:00
Constantin Graf
c0c678ac0d Use schema dump only for phpunit test runs 2024-06-30 19:42:10 +02:00
Constantin Graf
c036b77331 Added frankenphp local setup files to .gitignore 2024-06-30 19:41:27 +02:00
Constantin Graf
7b467807d9 Moved from swoole to frankenphp 2024-06-27 16:39:45 +02:00
Gregor Vostrak
2e8b088c59 improve project edit modal: fix enter submit on billable input and add labels 2024-06-24 18:32:43 +02:00
Gregor Vostrak
e69a419551 change cookie session default name to solidtime_session 2024-06-24 18:28:37 +02:00
Gregor Vostrak
a10d0569af fix token refresh on window focus, deactivate webkit playwright tests 2024-06-24 18:23:43 +02:00
Gregor Vostrak
237b3832bb use log driver for mailing in ci pipeline 2024-06-24 18:23:43 +02:00
Gregor Vostrak
eefa7c8ca8 fix focus & click behaviour of time range selector and task project dropdown modal 2024-06-24 18:23:43 +02:00
Gregor Vostrak
fc0a0615cb reenable playwright github action 2024-06-24 18:23:43 +02:00
Gregor Vostrak
3a61d68dc1 rename state change in useCurrentTimeEntry 2024-06-18 18:30:29 +02:00
Gregor Vostrak
0121195e75 focus on description after starting time tracker, ST-254 2024-06-18 18:28:45 +02:00
Gregor Vostrak
0c054bdcf2 improve focus handling for time entry create modal and update end date if start date is after end, fixes ST-250 2024-06-18 17:58:26 +02:00
Gregor Vostrak
96f818cb04 update minor dependencies, update playwright image 2024-06-18 17:29:09 +02:00
Constantin Graf
31ca0419f5 Updated composer dependencies; Changed dependency nwidart/laravel-modules to original repository 2024-06-18 17:01:57 +02:00
dependabot[bot]
78e35222f8 Bump docker/build-push-action from 5 to 6
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-18 16:45:22 +02:00
dependabot[bot]
c5b854adb3 Bump braces in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [braces](https://github.com/micromatch/braces).


Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-18 16:44:59 +02:00
Gregor Vostrak
9f374c7716 improve time picker focus handling and number input, fixes ST-251 2024-06-18 15:48:46 +02:00
Gregor Vostrak
ce8e503faa refresh stores on window focus, fixes ST-262 2024-06-18 13:47:00 +02:00
Gregor Vostrak
79f914d4b6 add partial patches to time entries store after time entry updates to avoid inconsistencies , fixes ST-259 2024-06-18 13:13:39 +02:00
Gregor Vostrak
c4757ee8a9 validate if date is valid before updating the value to prevent invalid dates sent to the server, fixes ST-255 2024-06-18 13:03:37 +02:00
Gregor Vostrak
c0212ec836 make activity graph chart resize on window resize, fixes ST-261 2024-06-18 12:57:07 +02:00
Gregor Vostrak
8f0c9afa1a remove user profile link from signup flow, fixes ST-260 2024-06-18 12:52:25 +02:00
Constantin Graf
8982bfac2b Fixed bug in user delete feature 2024-06-17 12:50:39 +02:00
Gregor Vostrak
9ac1d19722 change reporting default back 2024-06-13 21:12:41 +02:00
dependabot[bot]
843e16c4c0 Bump codecov/codecov-action from 4.4.1 to 4.5.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.4.1 to 4.5.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4.4.1...v4.5.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-13 16:26:56 +02:00
Constantin Graf
9a920bd4e9 Fixed migration 2024-06-13 16:26:44 +02:00
Constantin Graf
bb8c944df5 Added telescope migration for local setup 2024-06-13 16:26:44 +02:00
Constantin Graf
e4c1363193 Moved local setup to octane with swoole 2024-06-13 16:26:44 +02:00
Constantin Graf
bd9cede081 Prevent and remove zero values for billable rates 2024-06-13 16:26:44 +02:00
Gregor Vostrak
92dde6a701 fix timezone issues related to time entries spanning over a day 2024-06-12 17:06:10 +02:00
Gregor Vostrak
91cb6ab087 add formatting to importer descriptions, change reporting group limit for months 2024-06-10 18:01:32 +02:00
Gregor Vostrak
fadcd042c0 bg card color improvements 2024-06-10 17:34:09 +02:00
Constantin Graf
0eef5ffcfa Performance optimization for import 2024-06-10 17:33:07 +02:00
Constantin Graf
90480f3bb8 Updated composer dependencies minor 2024-06-10 17:33:07 +02:00
Constantin Graf
86f5ea47bb Added user and organization deletion system; Added coverage annotations 2024-06-09 13:58:46 +02:00
Gregor Vostrak
8857befc6c add loading indicator to import (ST-149), change card divider color 2024-06-07 14:18:27 +02:00
Constantin Graf
f40ae91444 Fixed timezone in time entries importer (tests) 2024-06-06 18:46:27 +02:00
Constantin Graf
94940be02c Fixed timezone in time entries importer 2024-06-06 18:35:19 +02:00
Gregor Vostrak
f2f128e184 remove misplaced create tag button from import screen 2024-06-06 18:30:04 +02:00
Gregor Vostrak
ffea3c6b68 fix time picker messing up the date when number exceeds 24 hours 2024-06-06 17:41:00 +02:00
Constantin Graf
1fdbfe77f0 Build images for all tags 2024-06-06 17:30:45 +02:00
Gregor Vostrak
7fb58ea341 add is_billable support for create / update project and in timetracker, fixes ST-217 2024-06-06 17:19:41 +02:00
Constantin Graf
d9244d1ab4 Fixed bug in user profile update validation that did not ignore placeholder users 2024-06-06 17:19:41 +02:00
Constantin Graf
b0cdeb3e33 Fixed test case and travelTo function in test cases 2024-06-06 17:19:41 +02:00
Constantin Graf
86555664c5 Added billable flag to projects 2024-06-06 17:19:41 +02:00
Constantin Graf
20f9b344f6 Added is_imported flag to time entries 2024-06-06 17:19:41 +02:00
Constantin Graf
802d9558a3 Fixed endOfWeek bug 2024-06-06 17:19:41 +02:00
Gregor Vostrak
474c0de3ac add update member billable rate , fixes ST-241 2024-06-05 18:36:00 +02:00
Gregor Vostrak
b1795392ad add date selector to time entries, fixes ST-134 2024-06-05 18:36:00 +02:00
Gregor Vostrak
2692db2a86 fix timetracker timepicker when no timer is started yet 2024-06-05 18:36:00 +02:00
Gregor Vostrak
ded58f8bd6 add edit modal for tasks and clients, fixes ST-233 2024-06-05 18:36:00 +02:00
Gregor Vostrak
81e3ffd921 add project add button to dropdown and set fixed height, fixes ST-170 2024-06-05 18:36:00 +02:00
Gregor Vostrak
22363e1c89 move from variable font to static fonts to fix safari rendering problems, fixes ST-230 2024-06-05 18:36:00 +02:00
Gregor Vostrak
d28269ebb0 add project member edit modal, fixes ST-119 2024-06-05 18:36:00 +02:00
Gregor Vostrak
3fc9d8b381 fix feedback bubble overlays elements, fixes ST-172 2024-06-05 18:36:00 +02:00
Gregor Vostrak
5bfd9e7dce add time picker to timetracker, fixes ST-180 2024-06-05 18:36:00 +02:00
Gregor Vostrak
ee6999af90 fix billable rate input field, fixes ST-203 2024-06-05 18:36:00 +02:00
Gregor Vostrak
22420439d9 fix placeholder alignment in time view 2024-06-05 18:36:00 +02:00
Gregor Vostrak
a065744d40 indent design bug fixes on time view, fixes ST-208 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4a7db27a05 reload stores after new team is created and data imported 2024-06-05 18:36:00 +02:00
Gregor Vostrak
bae4265f70 improve error handling for 401 requests and fetch 2024-06-05 18:36:00 +02:00
Gregor Vostrak
a64ee87d19 improve sidebar 2024-06-05 18:36:00 +02:00
Gregor Vostrak
c9311780ed restrict group and subgroup so they are always different 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4943baa236 add reporting client aggregation 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4c977b5bf8 add client_ids to reporting filters 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4e439010d1 hide admin submenu title for non-admins 2024-06-05 18:36:00 +02:00
225 changed files with 7677 additions and 2464 deletions

View File

@@ -31,12 +31,7 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_MAILER=log
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"

View File

@@ -6,8 +6,8 @@ APP_URL=https://solidtime.test
SUPER_ADMINS=admin@example.com
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_CHANNEL=single
LOG_DEPRECATIONS_CHANNEL=deprecation
LOG_LEVEL=debug
DB_CONNECTION=pgsql
@@ -73,3 +73,5 @@ NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
PAGINATION_PER_PAGE_DEFAULT=500

View File

@@ -4,7 +4,7 @@ APP_ENV=production
APP_DEBUG=false
APP_FORCE_HTTPS=true
SESSION_SECURE_COOKIE=true
OCTANE_SERVER=swoole
OCTANE_SERVER=frankenphp
PAGINATION_PER_PAGE_DEFAULT=500
LOG_CHANNEL=stack

View File

@@ -3,6 +3,8 @@ on:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-private.yml'
@@ -13,6 +15,8 @@ name: Build - Private
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Check out code"
uses: actions/checkout@v4
@@ -67,7 +71,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql, swoole
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies"
uses: php-actions/composer@v6
@@ -114,9 +118,11 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: "Build and push"
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
file: docker/prod/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}

View File

@@ -3,6 +3,8 @@ on:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-public.yml'
@@ -13,6 +15,8 @@ name: Build - Public
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Check out code"
uses: actions/checkout@v4
@@ -60,10 +64,12 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: "Build and push"
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}

View File

@@ -6,6 +6,7 @@ on:
jobs:
api_docs:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
pgsql_test:

View File

@@ -4,8 +4,8 @@ on: [push]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -4,8 +4,8 @@ on: [push]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -4,8 +4,8 @@ on: [push]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -3,6 +3,7 @@ on: push
jobs:
phpstan:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -3,6 +3,7 @@ on: push
jobs:
phpunit:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
pgsql_test:
@@ -54,7 +55,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v4.4.1
uses: codecov/codecov-action@v4.5.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -3,6 +3,8 @@ on: push
jobs:
pint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4

View File

@@ -1,10 +1,10 @@
name: Playwright Tests
on:
workflow_dispatch:
on: [push]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
timeout-minutes: 60
services:
mailpit:
image: 'axllent/mailpit:latest'

8
.gitignore vendored
View File

@@ -33,3 +33,11 @@ yarn-error.log
/k8s
/_ide_helper.php
/.phpstorm.meta.php
/.rnd
/caddy
/frankenphp
/public/frankenphp-worker.php
/data
/config/caddy
/config/composer

View File

@@ -7,9 +7,11 @@ namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
@@ -24,11 +26,33 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
'timezone' => ['required', 'timezone:all'],
'week_start' => ['required', Rule::enum(Weekday::class)],
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'email',
'max:255',
(new UniqueEloquent(User::class, 'email'))->ignore($user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false);
}),
],
'photo' => [
'nullable',
'mimes:jpg,jpeg,png',
'max:1024',
],
'timezone' => [
'required',
'timezone:all',
],
'week_start' => [
'required',
Rule::enum(Weekday::class),
],
])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Service\DeletionService;
use Laravel\Jetstream\Contracts\DeletesTeams;
class DeleteOrganization implements DeletesTeams
@@ -12,8 +13,8 @@ class DeleteOrganization implements DeletesTeams
/**
* Delete the given team.
*/
public function delete(Organization $team): void
public function delete(Organization $organization): void
{
$team->purge();
app(DeletionService::class)->deleteOrganization($organization);
}
}

View File

@@ -4,51 +4,25 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Exceptions\Api\ApiException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Laravel\Jetstream\Contracts\DeletesTeams;
use App\Service\DeletionService;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\DeletesUsers;
class DeleteUser implements DeletesUsers
{
/**
* The team deleter implementation.
*
* @var \Laravel\Jetstream\Contracts\DeletesTeams
*/
protected $deletesTeams;
/**
* Create a new action instance.
*/
public function __construct(DeletesTeams $deletesTeams)
{
$this->deletesTeams = $deletesTeams;
}
/**
* Delete the given user.
*/
public function delete(User $user): void
{
DB::transaction(function () use ($user) {
$this->deleteTeams($user);
$user->deleteProfilePhoto();
$user->tokens->each->delete();
$user->delete();
});
}
/**
* Delete the teams and team associations attached to the user.
*/
protected function deleteTeams(User $user): void
{
$user->teams()->detach();
$user->ownedTeams->each(function (Organization $team) {
$this->deletesTeams->delete($team);
});
try {
app(DeletionService::class)->deleteUser($user);
} catch (ApiException $exception) {
throw ValidationException::withMessages([
'password' => $exception->getTranslatedMessage(),
]);
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
class ValidateOrganizationDeletion
{
/**
* Validate that the team can be deleted by the given user.
*
* @param User $user Authenticated user
* @param Organization $organization Organization to be deleted
*
* @throws AuthorizationException
*/
public function validate(User $user, Organization $organization): void
{
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
throw new AuthorizationException();
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Admin;
use App\Models\Organization;
use App\Service\DeletionService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class DeleteOrganizationCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:delete-organization
{ organization : The ID of the organization to delete }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete a organization.';
/**
* Execute the console command.
*/
public function handle(DeletionService $deletionService): int
{
$organizationId = $this->argument('organization');
if (! Str::isUuid($organizationId)) {
$this->error('Organization ID must be a valid UUID.');
return self::FAILURE;
}
/** @var Organization|null $organization */
$organization = Organization::find($organizationId);
if ($organization === null) {
$this->error('Organization with ID '.$organizationId.' not found.');
return self::FAILURE;
}
$this->info('Deleting organization with ID '.$organization->getKey());
$deletionService->deleteOrganization($organization);
$this->info('Organization with ID '.$organization->getKey().' has been deleted.');
return self::SUCCESS;
}
}

View File

@@ -9,7 +9,7 @@ use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
use phpseclib3\Crypt\RSA;
class SelfHostGenerateKeys extends Command
class SelfHostGenerateKeysCommand extends Command
{
/**
* The name and signature of the console command.

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
class BeforeOrganizationDeletion
{
use Dispatchable;
public Organization $organization;
public function __construct(Organization $organization)
{
$this->organization = $organization;
}
}

View File

@@ -13,6 +13,11 @@ abstract class ApiException extends Exception
{
public const string KEY = 'api_exception';
public function __construct()
{
parent::__construct(static::KEY);
}
/**
* Render the exception into an HTTP response.
*/

View File

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

View File

@@ -12,7 +12,7 @@ class EntityStillInUseApiException extends ApiException
public function __construct(string $modelToDelete, string $modelInUse)
{
parent::__construct('', 0, null);
parent::__construct();
$this->modelToDelete = $modelToDelete;
$this->modelInUse = $modelInUse;
}

View File

@@ -65,6 +65,11 @@ class OrganizationResource extends Resource
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
'nullable',
'integer',
'gt:0',
])
->numeric(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
@@ -169,7 +174,6 @@ class OrganizationResource extends Resource
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Actions;
use App\Exceptions\Api\ApiException;
use App\Models\Organization;
use App\Service\DeletionService;
use Filament\Actions\DeleteAction;
use Throwable;
class DeleteOrganization extends DeleteAction
{
protected function setUp(): void
{
parent::setUp();
// TODO: check why setting the icon is necessary
$this->icon('heroicon-m-trash');
$this->action(function (): void {
$result = $this->process(function (Organization $record): bool {
try {
$deletionService = app(DeletionService::class);
$deletionService->deleteOrganization($record);
return true;
} catch (ApiException $exception) {
$this->failureNotificationTitle($exception->getTranslatedMessage());
report($exception);
} catch (Throwable $exception) {
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
report($exception);
}
return false;
});
if (! $result) {
$this->failure();
return;
}
$this->success();
});
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganization extends EditRecord
@@ -15,7 +14,7 @@ class EditOrganization extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
OrganizationResource\Actions\DeleteOrganization::make(),
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
@@ -18,8 +17,6 @@ class ViewOrganization extends ViewRecord
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
DeleteAction::make('delete')
->icon('heroicon-s-trash'),
];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectMemberResource\Pages;
use App\Models\ProjectMember;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class ProjectMemberResource extends Resource
{
protected static ?string $model = ProjectMember::class;
protected static bool $shouldRegisterNavigation = false;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
'nullable',
'integer',
'gt:0',
])
->numeric(),
Forms\Components\Select::make('user_id')
->relationship('user', 'name')
->required(),
Forms\Components\Select::make('member_id')
->relationship('member', 'id')
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID'),
Tables\Columns\TextColumn::make('billable_rate')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('project.name'),
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListProjectMembers::route('/'),
'create' => Pages\CreateProjectMember::route('/create'),
'edit' => Pages\EditProjectMember::route('/{record}/edit'),
'view' => Pages\ViewProjectMembers::route('/{record}'),
];
}
}

View File

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

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditProjectMember extends EditRecord
{
protected static string $resource = ProjectMemberResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource\RelationManagers\ProjectMembersRelationManager;
use App\Models\Project;
use Filament\Forms;
use Filament\Forms\Components\ColorPicker;
@@ -37,6 +38,15 @@ class ProjectResource extends Resource
ColorPicker::make('color')
->label('Color')
->required(),
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
'nullable',
'integer',
'gt:0',
])
->numeric(),
Forms\Components\Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
@@ -78,7 +88,7 @@ class ProjectResource extends Resource
public static function getRelations(): array
{
return [
//
ProjectMembersRelationManager::make(),
];
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\RelationManagers;
use App\Filament\Resources\ProjectMemberResource;
use App\Models\ProjectMember;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
class ProjectMembersRelationManager extends RelationManager
{
protected static ?string $title = 'Project Members';
protected static string $relationship = 'members';
public function form(Form $form): Form
{
return $form
->schema([
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('billable_rate')
->numeric()
->sortable(),
])
->filters([
//
])
->headerActions([
])
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('view', [
'record' => $record->getKey(),
])),
Action::make('edit')
->icon('heroicon-o-pencil')
->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('edit', [
'record' => $record->getKey(),
]))
->openUrlInNewTab(),
])
->bulkActions([
]);
}
}

View File

@@ -11,6 +11,7 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class TagResource extends Resource
@@ -58,7 +59,9 @@ class TagResource extends Resource
])
->defaultSort('created_at', 'desc')
->filters([
//
SelectFilter::make('organization')
->relationship('organization', 'name')
->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Actions;
use App\Exceptions\Api\ApiException;
use App\Models\User;
use App\Service\DeletionService;
use Filament\Actions\DeleteAction;
use Throwable;
class DeleteUser extends DeleteAction
{
protected function setUp(): void
{
parent::setUp();
$this->icon('heroicon-m-trash');
$this->action(function (): void {
$result = $this->process(function (User $record): bool {
try {
$deletionService = app(DeletionService::class);
$deletionService->deleteUser($record);
return true;
} catch (ApiException $exception) {
$this->failureNotificationTitle($exception->getTranslatedMessage());
report($exception);
} catch (Throwable $exception) {
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
report($exception);
}
return false;
});
if (! $result) {
$this->failure();
return;
}
$this->success();
});
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
@@ -17,7 +16,7 @@ class EditUser extends EditRecord
{
return [
Impersonate::make()->record($this->getRecord()),
Actions\DeleteAction::make(),
UserResource\Actions\DeleteUser::make(),
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
@@ -18,8 +17,6 @@ class ViewUser extends ViewRecord
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
DeleteAction::make('delete')
->icon('heroicon-s-trash'),
];
}
}

View File

@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Builder;
class ActiveUserOverview extends BaseWidget
{
protected static ?int $sort = 1;
protected static ?string $heading = 'A Registrations';
protected function getCards(): array

View File

@@ -15,6 +15,8 @@ class TimeEntriesCreated extends ChartWidget
public ?string $filter = 'week';
protected static ?int $sort = 3;
protected function getData(): array
{
$filter = $this->filter;
@@ -27,7 +29,9 @@ class TimeEntriesCreated extends ChartWidget
} else {
$start = now()->subWeek();
}
$trend = Trend::model(TimeEntry::class)
$trend = Trend::query(
TimeEntry::query()->where('is_imported', '=', false)
)
->between(
start: $start,
end: now(),

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Models\TimeEntry;
use Filament\Widgets\ChartWidget;
use Flowframe\Trend\Trend;
use Flowframe\Trend\TrendValue;
class TimeEntriesImported extends ChartWidget
{
protected static ?string $heading = 'Time Entries Imported';
public ?string $filter = 'week';
protected static ?int $sort = 4;
protected function getData(): array
{
$filter = $this->filter;
if ($filter === 'week') {
$start = now()->subWeek();
} elseif ($filter === 'month') {
$start = now()->subMonth();
} elseif ($filter === 'year') {
$start = now()->subYear();
} else {
$start = now()->subWeek();
}
$trend = Trend::query(
TimeEntry::query()->where('is_imported', '=', true)
)
->between(
start: $start,
end: now(),
)
->perDay();
if ($filter === 'week') {
$trend->perDay();
} elseif ($filter === 'month') {
$trend->perDay();
} elseif ($filter === 'year') {
$trend->perMonth();
} else {
$trend->perDay();
}
$data = $trend->count();
return [
'datasets' => [
[
'label' => self::$heading,
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
],
],
'labels' => $data->map(fn (TrendValue $value) => $value->date),
];
}
protected function getFilters(): ?array
{
return [
'week' => 'Last week',
'month' => 'Last month',
'year' => 'Last year',
];
}
protected function getType(): string
{
return 'line';
}
}

View File

@@ -15,6 +15,8 @@ class UserRegistrations extends ChartWidget
public ?string $filter = 'week';
protected static ?int $sort = 2;
protected function getData(): array
{
$filter = $this->filter;

View File

@@ -4,13 +4,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class Controller extends \App\Http\Controllers\Controller
{
@@ -48,34 +44,4 @@ class Controller extends \App\Http\Controllers\Controller
{
return $this->permissionStore->has($organization, $permission);
}
/**
* @throws AuthorizationException
*/
protected function user(): User
{
/** @var User|null $user */
$user = Auth::user();
if ($user === null) {
Log::error('This function should only be called in authenticated context');
throw new AuthorizationException();
}
return $user;
}
/**
* @throws AuthorizationException
*/
protected function member(Organization $organization): Member
{
$user = $this->user();
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
throw new AuthorizationException();
}
return $member;
}
}

View File

@@ -14,6 +14,8 @@ class OrganizationController extends Controller
/**
* Get organization
*
* @operationId getOrganization
*
* @throws AuthorizationException
*/
public function show(Organization $organization): OrganizationResource
@@ -26,6 +28,8 @@ class OrganizationController extends Controller
/**
* Update organization
*
* @operationId updateOrganization
*
* @throws AuthorizationException
*/
public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource
@@ -33,7 +37,7 @@ class OrganizationController extends Controller
$this->checkPermission($organization, 'organizations:update');
$organization->name = $request->input('name');
$organization->billable_rate = $request->input('billable_rate');
$organization->billable_rate = $request->getBillableRate();
$organization->save();
return new OrganizationResource($organization);

View File

@@ -85,7 +85,8 @@ class ProjectController extends Controller
$project = new Project();
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->billable_rate = $request->input('billable_rate');
$project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
$project->organization()->associate($organization);
$project->save();
@@ -105,7 +106,8 @@ class ProjectController extends Controller
$this->checkPermission($organization, 'projects:update', $project);
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->billable_rate = $request->input('billable_rate');
$project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
$project->save();

View File

@@ -71,7 +71,7 @@ class ProjectMemberController extends Controller
}
$projectMember = new ProjectMember();
$projectMember->billable_rate = $request->input('billable_rate');
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->member()->associate($member);
$projectMember->user()->associate($member->user);
$projectMember->project()->associate($project);
@@ -90,7 +90,7 @@ class ProjectMemberController extends Controller
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request): JsonResource
{
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
$projectMember->billable_rate = $request->input('billable_rate');
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->save();
return new ProjectMemberResource($projectMember);

View File

@@ -4,11 +4,63 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
use AuthorizesRequests;
use ValidatesRequests;
/**
* @throws AuthorizationException
*/
protected function user(): User
{
/** @var User|null $user */
$user = Auth::user();
if ($user === null) {
Log::error('This function should only be called in authenticated context');
throw new AuthorizationException();
}
return $user;
}
/**
* @throws AuthorizationException
*/
protected function member(Organization $organization): Member
{
$user = $this->user();
/** @var Member|null $member */
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
throw new AuthorizationException();
}
return $member;
}
/**
* @throws AuthorizationException
*/
protected function currentOrganization(): Organization
{
$user = $this->user();
$organization = $user->currentTeam;
if ($organization === null) {
$organization = $user->organizations()->first();
}
return $organization;
}
}

View File

@@ -4,21 +4,21 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Models\Organization;
use App\Models\User;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
/**
* @throws AuthorizationException
*/
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
{
/** @var User $user */
$user = auth()->user();
/** @var Organization $organization */
$organization = $user->currentTeam;
$user = $this->user();
$organization = $this->currentOrganization();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);

View File

@@ -36,4 +36,11 @@ class MemberUpdateRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -33,4 +33,11 @@ class OrganizationUpdateRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -38,6 +38,10 @@ class ProjectStoreRequest extends FormRequest
'max:255',
new ColorRule(),
],
'is_billable' => [
'required',
'boolean',
],
'billable_rate' => [
'nullable',
'integer',
@@ -52,4 +56,11 @@ class ProjectStoreRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -37,6 +37,10 @@ class ProjectUpdateRequest extends FormRequest
'max:255',
new ColorRule(),
],
'is_billable' => [
'required',
'boolean',
],
'billable_rate' => [
'nullable',
'integer',
@@ -51,4 +55,11 @@ class ProjectUpdateRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -39,4 +39,11 @@ class ProjectMemberStoreRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -28,4 +28,11 @@ class ProjectMemberUpdateRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -86,6 +87,19 @@ class TimeEntryAggregateRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by tag IDs, tag IDs are AND combined
'tag_ids' => [
'array',

View File

@@ -25,7 +25,7 @@ class OrganizationResource extends BaseResource
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string $color Personal organizations automatically created after registration */
/** @var bool $color Personal organizations automatically created after registration */
'is_personal' => $this->resource->personal_team,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,

View File

@@ -31,6 +31,8 @@ class ProjectResource extends BaseResource
'client_id' => $this->resource->client_id,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
/** @var bool $is_billable Project time entries billable default */
'is_billable' => $this->resource->is_billable,
];
}
}

View File

@@ -20,6 +20,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $organization_id
* @property string $client_id
* @property int|null $billable_rate
* @property bool $is_billable
* @property-read Organization $organization
* @property-read Client|null $client
* @property-read Collection<int, Task> $tasks
@@ -43,6 +44,15 @@ class Project extends Model
'color' => 'string',
];
/**
* Set default values for attributes.
*
* @var array<string, mixed>
*/
protected $attributes = [
'is_billable' => false,
];
/**
* @return BelongsTo<Organization, Project>
*/

View File

@@ -25,6 +25,7 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes;
* @property array $tags
* @property string $user_id
* @property string $member_id
* @property bool $is_imported
* @property-read User $user
* @property-read Member $member
* @property string $organization_id
@@ -57,6 +58,7 @@ class TimeEntry extends Model
'billable' => 'bool',
'tags' => 'array',
'billable_rate' => 'int',
'is_imported' => 'bool',
];
/**

View File

@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -36,11 +37,12 @@ use Laravel\Passport\HasApiTokens;
* @property bool $is_placeholder
* @property Weekday $week_start
* @property string|null $profile_photo_path
* @property-read Organization $currentTeam
* @property-read Organization|null $currentOrganization
* @property-read Organization|null $currentTeam
* @property-read string $profile_photo_url
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string $current_team_id
* @property string|null $current_team_id
* @property Collection<int, Organization> $organizations
* @property Collection<int, TimeEntry> $timeEntries
* @property Member $membership
@@ -154,6 +156,14 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
return $this->hasMany(TimeEntry::class);
}
/**
* @return BelongsTo<Organization, User>
*/
public function currentOrganization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'current_team_id');
}
/**
* @return HasMany<ProjectMember>
*/

View File

@@ -6,6 +6,7 @@ namespace App\Providers\Filament;
use App\Filament\Widgets\ActiveUserOverview;
use App\Filament\Widgets\TimeEntriesCreated;
use App\Filament\Widgets\TimeEntriesImported;
use App\Filament\Widgets\UserRegistrations;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
@@ -46,6 +47,7 @@ class AdminPanelProvider extends PanelProvider
ActiveUserOverview::class,
UserRegistrations::class,
TimeEntriesCreated::class,
TimeEntriesImported::class,
])
->plugins([
EnvironmentIndicatorPlugin::make()

View File

@@ -12,6 +12,7 @@ use App\Actions\Jetstream\InviteOrganizationMember;
use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateMemberRole;
use App\Actions\Jetstream\UpdateOrganization;
use App\Actions\Jetstream\ValidateOrganizationDeletion;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Member;
@@ -26,6 +27,7 @@ use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
use Laravel\Jetstream\Jetstream;
class JetstreamServiceProvider extends ServiceProvider
@@ -56,6 +58,7 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::useMembershipModel(Member::class);
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
Fortify::registerView(function () {
return Inertia::render('Auth/Register', [
'terms_url' => config('auth.terms_url'),
@@ -105,6 +108,7 @@ class JetstreamServiceProvider extends ServiceProvider
'clients:delete',
'organizations:view',
'organizations:update',
'organizations:delete',
'import',
'invitations:view',
'invitations:create',

View File

@@ -12,6 +12,27 @@ use App\Models\TimeEntry;
class BillableRateService
{
public function getBillableRateForTimeEntryWithGivenRelations(TimeEntry $timeEntry, ?ProjectMember $projectMember, ?Project $project, ?Member $member, ?Organization $organization): ?int
{
if (! $timeEntry->billable) {
return null;
}
if ($projectMember !== null && $projectMember->billable_rate !== null) {
return $projectMember->billable_rate;
}
if ($project !== null && $project->billable_rate !== null) {
return $project->billable_rate;
}
if ($member !== null && $member->billable_rate !== null) {
return $member->billable_rate;
}
if ($organization !== null && $organization->billable_rate !== null) {
return $organization->billable_rate;
}
return null;
}
public function getBillableRateForTimeEntry(TimeEntry $timeEntry): ?int
{
if (! $timeEntry->billable) {

View File

@@ -122,7 +122,7 @@ class DashboardService
{
return $builder->whereBetween('start', [
Carbon::now($timeZone)->startOfWeek($startOfWeek->carbonWeekDay())->utc(),
Carbon::now($timeZone)->endOfWeek($startOfWeek->carbonWeekDay())->utc(),
Carbon::now($timeZone)->endOfWeek($startOfWeek->toEndOfWeek()->carbonWeekDay())->utc(),
]);
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DeletionService
{
private UserService $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
{
if ($inTransaction) {
DB::transaction(function () use ($organization) {
$this->deleteOrganization($organization, false);
});
return;
}
Log::debug('Start deleting organization', [
'organization_id' => $organization->getKey(),
'name' => $organization->name,
'owner_id' => $organization->user_id,
]);
BeforeOrganizationDeletion::dispatch($organization);
// Delete all organization invitations
OrganizationInvitation::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all time entries
TimeEntry::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all tags
Tag::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all tasks
Task::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all project members
ProjectMember::query()->whereBelongsToOrganization($organization)->delete();
// Delete all projects
Project::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all clients
Client::query()->whereBelongsTo($organization, 'organization')->delete();
// Reset the current organization
$organization->owner()
->where('current_team_id', $organization->getKey())
->update(['current_team_id' => null]);
$organization->users()
->where('current_team_id', $organization->getKey())
->update(['current_team_id' => null]);
// Delete all members
$users = $organization->users()
->with([
'currentOrganization',
])
->get();
$organization->users()->sync([]);
// Make sure all users have at least one organization and delete placeholders
foreach ($users as $user) {
if ($ignoreUser !== null && $user->is($ignoreUser)) {
continue;
}
if ($user->is_placeholder) {
$user->delete();
} else {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
}
// Delete organization
$organization->delete();
Log::debug('Finished deleting organization', [
'organization_id' => $organization->getKey(),
'name' => $organization->name,
'owner_id' => $organization->user_id,
]);
}
/**
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers
*/
public function deleteUser(User $user, bool $inTransaction = true): void
{
if ($inTransaction) {
DB::transaction(function () use ($user) {
$this->deleteUser($user, false);
});
return;
}
Log::debug('Start deleting user', [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
]);
$members = Member::query()->whereBelongsTo($user, 'user')
->with([
'organization',
'user',
])
->get();
foreach ($members as $member) {
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers();
}
}
/** @var Member $member */
foreach ($members as $member) {
if ($member->role === Role::Owner->value) {
$this->deleteOrganization($member->organization, false, $user);
} else {
$this->userService->makeMemberToPlaceholder($member);
}
}
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
$user->deleteProfilePhoto();
$user->delete();
Log::debug('Finished deleting user', [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
]);
}
}

View File

@@ -30,6 +30,16 @@ class ImportDatabaseHelper
*/
private ?array $mapIdentifierToKey = null;
/**
* @var array<string, TModel|null>|null
*/
private ?array $mapKeyToModel = null;
/**
* @var array<string, TModel|null>|null
*/
private ?array $mapIdentifierToModel = null;
/**
* @var array<string, string>
*/
@@ -48,12 +58,14 @@ class ImportDatabaseHelper
*/
private array $validate;
private ?Closure $beforeSave;
/**
* @param class-string<TModel> $model
* @param array<string> $identifiers
* @param array<string, array<int, string>> $validate
*/
public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null, array $validate = [])
public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null, array $validate = [], ?Closure $beforeSave = null)
{
$this->model = $model;
$this->identifiers = $identifiers;
@@ -62,6 +74,7 @@ class ImportDatabaseHelper
$this->afterCreate = $afterCreate;
$this->createdCount = 0;
$this->validate = $validate;
$this->beforeSave = $beforeSave;
}
/**
@@ -89,6 +102,9 @@ class ImportDatabaseHelper
foreach ($data as $key => $value) {
$model->{$key} = $value;
}
if ($this->beforeSave !== null) {
($this->beforeSave)($model);
}
$model->save();
if ($this->afterCreate !== null) {
@@ -148,6 +164,47 @@ class ImportDatabaseHelper
}
}
/**
* @return TModel
*/
public function getModelById(string $id): ?Model
{
if ($this->mapKeyToModel === null) {
$this->mapKeyToModel = [];
}
if (isset($this->mapKeyToModel[$id])) {
return $this->mapKeyToModel[$id];
}
/** @var TModel|null $model */
$model = $this->getModelInstance()->find($id);
if ($model !== null) {
$this->mapKeyToModel[$id] = $model;
}
return $model;
}
/**
* @param array<string, mixed> $identifierData
* @return TModel|null
*/
public function getModel(array $identifierData): ?Model
{
if ($this->mapIdentifierToModel === null) {
$this->mapIdentifierToModel = [];
}
$hash = $this->getHash($identifierData);
if (isset($this->mapIdentifierToModel[$hash])) {
return $this->mapIdentifierToModel[$hash];
}
$model = $this->getModelInstance()->where($identifierData)->first();
if ($model !== null) {
$this->mapIdentifierToModel[$hash] = $model;
}
return $model;
}
/**
* @param array<string, mixed> $identifierData
*

View File

@@ -9,7 +9,7 @@ use App\Service\Import\Importers\ImporterContract;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use Carbon\Carbon;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

View File

@@ -41,6 +41,7 @@ class ClockifyProjectsImporter extends DefaultImporter
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
]);
}

View File

@@ -64,6 +64,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
], [
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
@@ -72,6 +73,8 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
}
$projectId = null;
$project = null;
$projectMember = null;
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
@@ -79,6 +82,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => false,
]);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
@@ -105,6 +114,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
}
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
// Start
try {
@@ -119,7 +129,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if ($start === null) {
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
}
$timeEntry->start = $start;
$timeEntry->start = $start->utc();
// End
try {
@@ -134,8 +144,14 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if ($end === null) {
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
}
$timeEntry->end = $end;
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}

View File

@@ -12,6 +12,7 @@ use App\Models\ProjectMember;
use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use App\Service\BillableRateService;
use App\Service\ColorService;
use App\Service\Import\ImportDatabaseHelper;
use App\Service\TimezoneService;
@@ -62,6 +63,8 @@ abstract class DefaultImporter implements ImporterContract
*/
protected ImportDatabaseHelper $projectMemberImportHelper;
protected BillableRateService $billableRateService;
public function init(Organization $organization): void
{
$this->organization = $organization;
@@ -96,11 +99,19 @@ abstract class DefaultImporter implements ImporterContract
'required',
'max:255',
],
'is_billable' => [
'required',
'boolean',
],
'billable_rate' => [
'nullable',
'integer',
],
]);
], beforeSave: function (Project $project) {
if ($project->billable_rate === 0) {
$project->billable_rate = null;
}
});
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder) {
/** @var Builder<ProjectMember> $builder */
return $builder->whereBelongsToOrganization($this->organization);
@@ -109,7 +120,11 @@ abstract class DefaultImporter implements ImporterContract
'nullable',
'integer',
],
]);
], beforeSave: function (ProjectMember $projectMember) {
if ($projectMember->billable_rate === 0) {
$projectMember->billable_rate = null;
}
});
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) {
return $builder->where('organization_id', $this->organization->id);
}, validate: [
@@ -137,6 +152,7 @@ abstract class DefaultImporter implements ImporterContract
$this->timeEntriesCreated = 0;
$this->colorService = app(ColorService::class);
$this->timezoneService = app(TimezoneService::class);
$this->billableRateService = app(BillableRateService::class);
}
#[\Override]

View File

@@ -121,6 +121,7 @@ class TogglDataImporter extends DefaultImporter
], [
'client_id' => $clientId,
'color' => $project->color,
'is_billable' => $project->rate !== null,
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
], (string) $project->id);

View File

@@ -64,6 +64,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
], [
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
@@ -72,14 +73,22 @@ class TogglTimeEntriesImporter extends DefaultImporter
]);
}
$projectId = null;
$project = null;
$projectMember = null;
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'is_billable' => false,
'color' => $this->colorService->getRandomColor(),
]);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
if ($record['Task'] !== '') {
@@ -102,6 +111,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
}
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
try {
$start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], $timezone);
} catch (InvalidFormatException) {
@@ -110,7 +120,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
if ($start === null) {
throw new ImportException('Start date ("'.$record['Start date'].'") or time ("'.$record['Start time'].'") are invalid');
}
$timeEntry->start = $start;
$timeEntry->start = $start->utc();
try {
$end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], $timezone);
@@ -120,8 +130,14 @@ class TogglTimeEntriesImporter extends DefaultImporter
if ($end === null) {
throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid');
}
$timeEntry->end = $end;
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}

View File

@@ -30,6 +30,11 @@ class PermissionStore
return false;
}
return $this->userHas($organization, $user, $permission);
}
public function userHas(Organization $organization, User $user, string $permission): bool
{
if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {
if (! $user->belongsToTeam($organization)) {
return false;

View File

@@ -28,6 +28,11 @@ class UserService
throw new \InvalidArgumentException('User is not a member of the organization');
}
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
}
private function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
{
// Time entries
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
@@ -47,6 +52,53 @@ class UserService
]);
}
public function makeMemberToPlaceholder(Member $member): void
{
$user = $member->user;
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
$placeholderUser->save();
$member->user()->associate($placeholderUser);
$member->role = Role::Placeholder->value;
$member->save();
$this->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
$this->makeSureUserHasAtLeastOneOrganization($user);
}
public function makeSureUserHasAtLeastOneOrganization(User $user): void
{
if ($user->organizations()->count() > 0) {
return;
}
// Create a new organization
$organization = new Organization();
$organization->name = $user->name."'s Organization";
$organization->personal_team = true;
$organization->user_id = $user->id;
$organization->save();
// Attach the user to the organization
$organization->users()->attach($user, ['role' => Role::Owner->value]);
// Set the organization as the user's current organization
$user->currentOrganization()->associate($organization);
$user->save();
}
public function makeSureUserHasCurrentOrganization(User $user): void
{
if ($user->currentOrganization !== null) {
return;
}
$organization = $user->organizations()->first();
$user->currentOrganization()->associate($organization);
$user->save();
}
/**
* Change the ownership of an organization to a new user.
* The previous owner will be demoted to an admin.

View File

@@ -8,7 +8,7 @@
"require": {
"php": "8.3.*",
"ext-zip": "*",
"brick/money": "^0.8.1",
"brick/money": "^0.9.0",
"dedoc/scramble": "dev-main",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.2.0",
@@ -22,12 +22,12 @@
"laravel/passport": "^12.0",
"laravel/tinker": "^2.8",
"league/flysystem-aws-s3-v3": "^3.0",
"nwidart/laravel-modules": "dev-feature/fixed_path",
"nwidart/laravel-modules": "^11.0.11",
"pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2",
"stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0",
"tpetry/laravel-postgresql-enhanced": "^0.38.0",
"tpetry/laravel-postgresql-enhanced": "^0.39.0",
"wikimedia/composer-merge-plugin": "^2.1.0"
},
"require-dev": {
@@ -93,12 +93,18 @@
"test:coverage:report": [
"@php vendor/bin/phpunit --coverage-html=coverage"
],
"coverage-report": [
"@test:coverage:report"
],
"fix": [
"@php pint"
],
"ide-helper": [
"@php artisan ide-helper:generate",
"@php artisan ide-helper:meta"
],
"refresh-schema-dump": [
"@php artisan schema:dump --database=\"pgsql_test\""
]
},
"extra": {
@@ -112,10 +118,6 @@
{
"type": "vcs",
"url": "https://github.com/korridor/scramble"
},
{
"type": "vcs",
"url": "https://github.com/korridor/laravel-modules"
}
],
"config": {

1022
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -144,6 +144,11 @@ return [
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
'deprecation' => [
'driver' => 'single',
'path' => storage_path('logs/deprecation.log'),
],
],
];

View File

@@ -14,10 +14,13 @@ use Laravel\Octane\Events\WorkerErrorOccurred;
use Laravel\Octane\Events\WorkerStarting;
use Laravel\Octane\Events\WorkerStopping;
use Laravel\Octane\Listeners\CloseMonologHandlers;
use Laravel\Octane\Listeners\CollectGarbage;
use Laravel\Octane\Listeners\DisconnectFromDatabases;
use Laravel\Octane\Listeners\EnsureUploadedFilesAreValid;
use Laravel\Octane\Listeners\EnsureUploadedFilesCanBeMoved;
use Laravel\Octane\Listeners\FlushOnce;
use Laravel\Octane\Listeners\FlushTemporaryContainerInstances;
use Laravel\Octane\Listeners\FlushUploadedFiles;
use Laravel\Octane\Listeners\ReportException;
use Laravel\Octane\Listeners\StopWorkerIfNecessary;
use Laravel\Octane\Octane;
@@ -37,7 +40,7 @@ return [
|
*/
'server' => env('OCTANE_SERVER', 'swoole'),
'server' => env('OCTANE_SERVER', 'frankenphp'),
/*
|--------------------------------------------------------------------------
@@ -185,7 +188,7 @@ return [
'watch' => [
'app',
'bootstrap',
'config',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
use Illuminate\Support\Str;
return [
/*
@@ -130,7 +128,7 @@ return [
'cookie' => env(
'SESSION_COOKIE',
Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
'solidtime_session'
),
/*

View File

@@ -22,7 +22,7 @@ class OrganizationFactory extends Factory
{
return [
'name' => $this->faker->unique()->company(),
'currency' => $this->faker->currencyCode,
'currency' => $this->faker->currencyCode(),
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
'user_id' => User::factory(),
'personal_team' => true,

View File

@@ -27,13 +27,24 @@ class ProjectFactory extends Factory
return [
'name' => $this->faker->company(),
'color' => app(ColorService::class)->getRandomColor(),
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
'is_billable' => false,
'billable_rate' => null,
'is_public' => false,
'client_id' => null,
'organization_id' => Organization::factory(),
];
}
public function billable(): self
{
return $this->state(function (array $attributes): array {
return [
'is_billable' => true,
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
];
});
}
public function forOrganization(Organization $organization): self
{
return $this->state(function (array $attributes) use ($organization): array {

View File

@@ -33,6 +33,7 @@ class TimeEntryFactory extends Factory
'start' => $start,
'end' => $this->faker->dateTimeBetween($start, 'now'),
'billable' => $this->faker->boolean(),
'is_imported' => false,
'tags' => [],
'user_id' => User::factory(),
'member_id' => Member::factory(),

View File

@@ -9,6 +9,7 @@ use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
@@ -86,6 +87,20 @@ class UserFactory extends Factory
});
}
public function withProfilePicture(): static
{
$profilePhoto = $this->faker->image(null, 500, 500);
/** @see \Illuminate\Http\FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
return $this->state(function (array $attributes) use ($path): array {
return [
'profile_photo_path' => $path,
];
});
}
/**
* Indicate that the user should have a personal team.
*/

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return config('telescope.storage.database.connection');
}
/**
* Run the migrations.
*/
public function up(): void
{
if (! App::isLocal()) {
return;
}
$schema = Schema::connection($this->getConnection());
$schema->create('telescope_entries', function (Blueprint $table) {
$table->bigIncrements('sequence');
$table->uuid('uuid');
$table->uuid('batch_id');
$table->string('family_hash')->nullable();
$table->boolean('should_display_on_index')->default(true);
$table->string('type', 20);
$table->longText('content');
$table->dateTime('created_at')->nullable();
$table->unique('uuid');
$table->index('batch_id');
$table->index('family_hash');
$table->index('created_at');
$table->index(['type', 'should_display_on_index']);
});
$schema->create('telescope_entries_tags', function (Blueprint $table) {
$table->uuid('entry_uuid');
$table->string('tag');
$table->primary(['entry_uuid', 'tag']);
$table->index('tag');
$table->foreign('entry_uuid')
->references('uuid')
->on('telescope_entries')
->onDelete('cascade');
});
$schema->create('telescope_monitoring', function (Blueprint $table) {
$table->string('tag')->primary();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! App::isLocal()) {
return;
}
$schema = Schema::connection($this->getConnection());
$schema->dropIfExists('telescope_entries_tags');
$schema->dropIfExists('telescope_entries');
$schema->dropIfExists('telescope_monitoring');
}
};

View File

@@ -20,14 +20,14 @@ return new class extends Migration
$table->foreign('project_id')
->references('id')
->on('projects')
->onDelete('restrict')
->onUpdate('cascade');
->restrictOnDelete()
->cascadeOnUpdate();
$table->uuid('user_id');
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('restrict')
->onUpdate('cascade');
->restrictOnDelete()
->cascadeOnUpdate();
$table->timestamps();
$table->unique(['project_id', 'user_id']);
});

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->boolean('is_billable')->default(false);
});
DB::statement('
update projects
set is_billable = true
where projects.billable_rate is not null and projects.billable_rate > 0
');
Schema::table('projects', function (Blueprint $table) {
$table->boolean('is_billable')->default(null)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn('is_billable');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('time_entries', function (Blueprint $table) {
$table->boolean('is_imported')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table) {
$table->dropColumn('is_imported');
});
}
};

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('time_entries', function (Blueprint $table) {
$table->dropForeign(['member_id']);
$table->foreign('member_id')
->references('id')
->on('members')
->restrictOnDelete()
->cascadeOnUpdate();
$table->dropForeign(['client_id']);
$table->foreign('client_id')
->references('id')
->on('clients')
->restrictOnDelete()
->cascadeOnUpdate();
});
Schema::table('project_members', function (Blueprint $table) {
$table->dropForeign(['member_id']);
$table->foreign('member_id')
->references('id')
->on('members')
->restrictOnDelete()
->cascadeOnUpdate();
});
Schema::table('organization_invitations', function (Blueprint $table) {
$table->dropForeign(['organization_id']);
$table->foreign('organization_id')
->references('id')
->on('organizations')
->restrictOnDelete()
->cascadeOnUpdate();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table) {
$table->dropForeign(['member_id']);
$table->foreign('member_id')
->references('id')
->on('members')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->dropForeign(['client_id']);
$table->foreign('client_id')
->references('id')
->on('clients')
->cascadeOnDelete()
->cascadeOnUpdate();
});
Schema::table('project_members', function (Blueprint $table) {
$table->dropForeign(['member_id']);
$table->foreign('member_id')
->references('id')
->on('members')
->cascadeOnDelete()
->cascadeOnUpdate();
});
Schema::table('organization_invitations', function (Blueprint $table) {
$table->dropForeign(['organization_id']);
$table->foreign('organization_id')
->references('id')
->on('organizations')
->cascadeOnDelete()
->cascadeOnUpdate();
});
}
};

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('organizations')
->where('billable_rate', '=', 0)
->update(['billable_rate' => null]);
DB::table('project_members')
->where('billable_rate', '=', 0)
->update(['billable_rate' => null]);
DB::table('projects')
->where('billable_rate', '=', 0)
->update(['billable_rate' => null]);
DB::table('members')
->where('billable_rate', '=', 0)
->update(['billable_rate' => null]);
DB::table('time_entries')
->where('billable_rate', '=', 0)
->update(['billable_rate' => null]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@ class DatabaseSeeder extends Seeder
'personal_team' => false,
'currency' => 'EUR',
]);
$userRivalManager = User::factory()->withPersonalOrganization()->create([
$userAcmeManager = User::factory()->withPersonalOrganization()->create([
'name' => 'Acme Manager',
'email' => 'test@example.com',
]);
@@ -57,7 +57,7 @@ class DatabaseSeeder extends Seeder
'password' => null,
]);
$userAcmeOwnerMember = Member::factory()->forUser($userAcmeOwner)->forOrganization($organizationAcme)->role(Role::Owner)->create();
$userAcmeManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationAcme)->role(Role::Manager)->create();
$userAcmeManagerMember = Member::factory()->forUser($userAcmeManager)->forOrganization($organizationAcme)->role(Role::Manager)->create();
$userAcmeAdminMember = Member::factory()->forUser($userAcmeAdmin)->forOrganization($organizationAcme)->role(Role::Admin)->create();
$userAcmeEmployeeMember = Member::factory()->forUser($userAcmeEmployee)->forOrganization($organizationAcme)->role(Role::Employee)->create();
$userAcmePlaceholderMember = Member::factory()->forUser($userAcmePlaceholder)->forOrganization($organizationAcme)->role(Role::Placeholder)->create();
@@ -67,6 +67,10 @@ class DatabaseSeeder extends Seeder
->count(10)
->forMember($userAcmeAdminMember)
->create();
TimeEntry::factory()
->count(10)
->forMember($userAcmeManagerMember)
->create();
TimeEntry::factory()
->count(10)
->forMember($userAcmePlaceholderMember)
@@ -79,10 +83,10 @@ class DatabaseSeeder extends Seeder
->count(5)
->forMember($userWithMultipleOrganizationsAcmeMember)
->create();
$client = Client::factory()->forOrganization($organizationAcme)->create([
$acmeClient = Client::factory()->forOrganization($organizationAcme)->create([
'name' => 'Big Company',
]);
$bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($client)->create([
$bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($acmeClient)->create([
'name' => 'Big Company Project',
]);
ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeEmployeeMember)->create();
@@ -101,11 +105,11 @@ class DatabaseSeeder extends Seeder
'name' => 'Internal Project',
]);
$organization2Owner = User::factory()->create([
$rivalOwner = User::factory()->create([
'name' => 'Other Owner',
'email' => 'owner@rival-company.test',
]);
$organizationRival = Organization::factory()->withOwner($organization2Owner)->create([
$organizationRival = Organization::factory()->withOwner($rivalOwner)->create([
'name' => 'Rival Corp',
'personal_team' => true,
'currency' => 'USD',
@@ -116,9 +120,12 @@ class DatabaseSeeder extends Seeder
]);
$userRivalManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationRival)->role(Role::Admin)->create();
$userWithMultipleOrganizationsRivalMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationRival)->role(Role::Employee)->create();
$otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($client)->create([
$rivalClient = Client::factory()->forOrganization($organizationRival)->create([
'name' => 'Scale Company',
]);
$otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($rivalClient)->create([
'name' => 'Scale Company - Project ABC',
]);
ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userRivalManagerMember)->create();
ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userWithMultipleOrganizationsRivalMember)->create();
TimeEntry::factory()

View File

@@ -28,6 +28,9 @@ services:
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
SUPERVISOR_PHP_COMMAND: "/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan octane:start --server=frankenphp --host=0.0.0.0 --admin-port=2019 --port=80 --watch"
XDG_CONFIG_HOME: /var/www/html/config
XDG_DATA_HOME: /var/www/html/data
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
@@ -106,7 +109,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.42.1-jammy
image: mcr.microsoft.com/playwright:v1.44.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

View File

@@ -1,120 +1,178 @@
# Accepted values: 8.3 - 8.2
ARG PHP_VERSION=8.3
ARG DOCKER_FILES_BASE_PATH="docker/prod"
ARG FRANKENPHP_VERSION=latest
ARG COMPOSER_VERSION=latest
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
###########################################
# Build frontend assets with NPM
###########################################
#ARG NODE_VERSION=20-alpine
#
#FROM node:${NODE_VERSION} AS build
#
#ENV ROOT=/var/www/html
#
#WORKDIR ${ROOT}
#
#RUN npm config set update-notifier false && npm set progress=false
#
#COPY package*.json ./
#
#RUN if [ -f $ROOT/package-lock.json ]; \
# then \
# npm ci --loglevel=error --no-audit; \
# else \
# npm install --loglevel=error --no-audit; \
# fi
#
#COPY . .
#
#RUN npm run build
###########################################
FROM composer:${COMPOSER_VERSION} AS vendor
FROM php:${PHP_VERSION}-cli-bookworm AS base
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
ARG DOCKER_FILES_BASE_PATH
LABEL maintainer="solidtime <hello@solidtime.io>"
LABEL org.opencontainers.image.title="solidtime"
LABEL org.opencontainers.image.description="solidtime is a modern open source timetracker for Freelancers and Agencies"
LABEL org.opencontainers.image.description="solidtime is a modern open source timetracker for freelancers and agencies"
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
LABEL org.opencontainers.image.licenses="AGPL"
ARG WWWUSER=1000
ARG WWWGROUP=1000
ARG TZ=UTC
ARG APP_DIR=/var/www/html
ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-color \
WITH_HORIZON=false \
WITH_SCHEDULER=false \
OCTANE_SERVER=swoole \
USER=octane \
ROOT=/var/www/html \
COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=24
TERM=xterm-color \
WITH_HORIZON=false \
WITH_SCHEDULER=false \
OCTANE_SERVER=frankenphp \
USER=octane \
ROOT=${APP_DIR} \
COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=24 \
XDG_CONFIG_HOME=${APP_DIR}/.config \
XDG_DATA_HOME=${APP_DIR}/.data
WORKDIR ${ROOT}
SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
&& echo ${TZ} > /etc/timezone
RUN apt-get update; \
apt-get upgrade -yqq; \
apt-get install -yqq --no-install-recommends --show-progress \
apt-utils \
curl \
wget \
nano \
ncdu \
ca-certificates \
supervisor \
libsodium-dev \
# Install PHP extensions
&& install-php-extensions \
bz2 \
pcntl \
mbstring \
bcmath \
sockets \
pgsql \
pdo_pgsql \
opcache \
exif \
pdo_mysql \
zip \
intl \
gd \
redis \
rdkafka \
memcached \
igbinary \
ldap \
swoole \
&& apt-get -y autoremove \
&& apt-get clean \
&& docker-php-source delete \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& rm /var/log/lastlog /var/log/faillog
apt-get upgrade -yqq; \
apt-get install -yqq --no-install-recommends --show-progress \
apt-utils \
curl \
wget \
nano \
ncdu \
procps \
ca-certificates \
supervisor \
libsodium-dev \
# Install PHP extensions (included with dunglas/frankenphp)
&& install-php-extensions \
bz2 \
pcntl \
mbstring \
bcmath \
sockets \
pgsql \
pdo_pgsql \
opcache \
exif \
pdo_mysql \
zip \
intl \
gd \
redis \
rdkafka \
memcached \
igbinary \
ldap \
&& apt-get -y autoremove \
&& apt-get clean \
&& docker-php-source delete \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& rm /var/log/lastlog /var/log/faillog
RUN wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/supercronic-linux-amd64" \
-O /usr/bin/supercronic \
&& chmod +x /usr/bin/supercronic \
&& mkdir -p /etc/supercronic \
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --verbose --no-interaction" > /etc/supercronic/laravel
RUN arch="$(uname -m)" \
&& case "$arch" in \
armhf) _cronic_fname='supercronic-linux-arm' ;; \
aarch64) _cronic_fname='supercronic-linux-arm64' ;; \
x86_64) _cronic_fname='supercronic-linux-amd64' ;; \
x86) _cronic_fname='supercronic-linux-386' ;; \
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
esac \
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
-O /usr/bin/supercronic \
&& chmod +x /usr/bin/supercronic \
&& mkdir -p /etc/supercronic \
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
RUN userdel --remove --force www-data \
&& groupadd --force -g ${WWWGROUP} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER}
&& groupadd --force -g ${WWWGROUP} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER}
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
&& chmod -R a+rw /var/{log,run}
&& chmod -R a+rw ${ROOT} /var/{log,run}
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
USER ${USER}
COPY --chown=${USER}:${USER} --from=vendor /usr/bin/composer /usr/bin/composer
#COPY --chown=${USER}:${USER} composer.json composer.lock ./
#
#RUN composer install \
# --no-dev \
# --no-interaction \
# --no-autoloader \
# --no-ansi \
# --no-scripts \
# --audit
COPY --chown=${USER}:${USER} . .
#COPY --chown=${USER}:${USER} --from=build ${ROOT}/public public
RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \
storage/logs \
bootstrap/cache && chmod -R a+rw storage
storage/framework/{sessions,views,cache,testing} \
storage/logs \
bootstrap/cache && chmod -R a+rw storage
COPY --chown=${USER}:${USER} docker/prod/deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} docker/prod/deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
COPY --chown=${USER}:${USER} docker/prod/deployment/start-container /usr/local/bin/start-container
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/supervisor/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
RUN cat .env
RUN php artisan env
RUN php artisan storage:link
# FrankenPHP embedded PHP configuration
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/php.ini
#RUN composer install \
# --classmap-authoritative \
# --no-interaction \
# --no-ansi \
# --no-dev \
# && composer clear-cache
RUN chmod +x /usr/local/bin/start-container
RUN cat docker/prod/deployment/utilities.sh >> ~/.bashrc
RUN cat ${DOCKER_FILES_BASE_PATH}deployment/utilities.sh >> ~/.bashrc
EXPOSE 8000

View File

@@ -1,128 +0,0 @@
# Laravel Octane Dockerfile
<a href="/LICENSE"><img alt="License" src="https://img.shields.io/github/license/exaco/laravel-octane-dockerfile"></a>
<a href="https://github.com/exaco/laravel-octane-dockerfile/releases"><img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/exaco/laravel-octane-dockerfile"></a>
<a href="https://github.com/exaco/laravel-octane-dockerfile/pulls"><img alt="GitHub closed pull requests" src="https://img.shields.io/github/issues-pr-closed/exaco/laravel-octane-dockerfile"></a>
<a href="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/tests.yml"><img alt="GitHub Workflow Status" src="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/roadrunner-test.yml/badge.svg"></a>
<a href="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/tests.yml"><img alt="GitHub Workflow Status" src="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/swoole-test.yml/badge.svg"></a>
<a href="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/tests.yml"><img alt="GitHub Workflow Status" src="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/frankenphp-test.yml/badge.svg"></a>
Production-ready Dockerfiles for [Laravel Octane](https://github.com/laravel/octane)
powered web services and microservices.
The Docker configuration provides the following setup:
- PHP 8.2 and 8.3 official Debian-based images
- Preconfigured JIT compiler and OPcache
## Container modes
You can run the Docker container in different modes:
| Mode | `CONTAINER_MODE` | HTTP server |
| --------------------- | ---------------- | ------------------- |
| HTTP Server (default) | `http` | FrankenPHP / Swoole / RoadRunner |
| Horizon | `horizon` | - |
| Scheduler | `scheduler` | - |
| Worker | `worker` | - |
## Usage
### Building Docker image
1. Clone this repository:
```
git clone --depth 1 git@github.com:exaco/laravel-octane-dockerfile.git
```
2. Copy cloned directory content including `deployment` directory, `Dockerfile`, and `.dockerignore` into your Octane powered Laravel project
3. Change the directory to your Laravel project
4. Build your image:
```
docker build -t <image-name>:<tag> -f <your-octane-driver>.Dockerfile .
```
### Running Docker container
```bash
# HTTP mode
docker run -p <port>:80 --rm <image-name>:<tag>
# Horizon mode
docker run -e CONTAINER_MODE=horizon --rm <image-name>:<tag>
# Scheduler mode
docker run -e CONTAINER_MODE=scheduler --rm <image-name>:<tag>
# HTTP mode with Horizon
docker run -e WITH_HORIZON=true -p <port>:80 --rm <image-name>:<tag>
# HTTP mode with Scheduler
docker run -e WITH_SCHEDULER=true -p <port>:80 --rm <image-name>:<tag>
# HTTP mode with Scheduler and Horizon
docker run -e WITH_SCHEDULER=true -e WITH_HORIZON=true -p <port>:80 --rm <image-name>:<tag>
# Worker mode
docker run -e CONTAINER_MODE=worker -e WORKER_COMMAND="php /var/www/html/artisan foo:bar" --rm <image-name>:<tag>
# Running a single command
docker run --rm <image-name>:<tag> php artisan about
```
## Configuration
### Recommended `Swoole` options in `octane.php`
```php
// config/octane.php
return [
'swoole' => [
'options' => [
'http_compression' => true,
'http_compression_level' => 6, // 1 - 9
'compression_min_length' => 20,
'package_max_length' => 20 * 1024 * 1024, // 20MB
'open_http2_protocol' => true,
'document_root' => public_path(),
'enable_static_handler' => true,
]
]
];
```
## Utilities
Also, some useful Bash functions and aliases are added in `utilities.sh` that maybe help.
## Notes
- Laravel Octane logs request information only in the `local` environment.
- Please be aware of `.dockerignore` content
## ToDo
- [x] Add support for PHP 8.3
- [x] Add support for worker mode
- [ ] Build assets with Bun
- [ ] Create standalone and self-executable app
- [x] Add support for Horizon
- [x] Add support for RoadRunner
- [x] Add support for FrankenPHP
- [x] Add support for the full-stack apps (Front-end assets)
- [ ] Add support `testing` environment and CI
- [x] Add support for the Laravel scheduler
- [ ] Add support for Laravel Dusk
- [x] Support more PHP extensions
- [x] Add tests
- [ ] Add Alpine-based images
## Contributing
Thank you for considering contributing! If you find an issue, or have a better way to do something, feel free to open an
issue, or a PR.
## Credits
- [SMortexa](https://github.com/smortexa)
- [All contributors](https://github.com/exaco/laravel-octane-dockerfile/graphs/contributors)
## License
This repository is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -0,0 +1,51 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019
; command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=localhost --port=443 --admin-port=2019 --https --http-redirect
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes=200MB
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
startsecs=0
startretries=1
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,25 @@
version: '2.7'
rpc:
listen: 'tcp://127.0.0.1:6001'
server:
relay: pipes
http:
middleware: [ "static", "gzip", "headers" ]
max_request_size: 20
static:
dir: "public"
forbid: [ ".php", ".htaccess" ]
uploads:
forbid: [".php", ".exe", ".bat", ".sh"]
pool:
allocate_timeout: 10s
destroy_timeout: 10s
supervisor:
max_worker_memory: 128
exec_ttl: 60s
logs:
mode: production
level: debug
encoding: json
status:
address: localhost:2114

View File

@@ -0,0 +1,50 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8000 --rpc-port=6001 --rr-config=%(ENV_ROOT)s/.rr.yaml
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes=200MB
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
startsecs=0
startretries=1
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,50 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=swoole --host=0.0.0.0 --port=8000
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes=200MB
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
startsecs=0
startretries=1
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -4,6 +4,7 @@ upload_max_filesize = 100M
expose_php = 0
realpath_cache_size = 16M
realpath_cache_ttl = 360
max_input_time = 5
[Opcache]
opcache.enable = 1

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