mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee2f125062 | ||
|
|
fd8d596e9b | ||
|
|
555417dbbd | ||
|
|
7aab3d98fc | ||
|
|
1dc35f1f55 | ||
|
|
be50397775 | ||
|
|
e3b4cfd881 | ||
|
|
7fd5d25781 | ||
|
|
4c2748ff50 | ||
|
|
c69701aa66 | ||
|
|
c194785034 | ||
|
|
53e5805937 | ||
|
|
a8d82d0d2c | ||
|
|
8f0be6efce | ||
|
|
6593a8c24f | ||
|
|
0f32e42002 | ||
|
|
8ddce667cc | ||
|
|
726c2ee623 | ||
|
|
7decb095ee | ||
|
|
442da936d0 | ||
|
|
3a17ae83ae | ||
|
|
264b7c9b8d | ||
|
|
c3a7ef7585 | ||
|
|
de1accba4a | ||
|
|
364168debd | ||
|
|
75e739f6fb | ||
|
|
a69d1cb4c4 | ||
|
|
f21a2d4bdd | ||
|
|
512089ccbd | ||
|
|
313cee2db0 | ||
|
|
2184b3c835 | ||
|
|
7c26cee1ea | ||
|
|
ce82dddc6a | ||
|
|
099926f95c | ||
|
|
42da2c3397 | ||
|
|
62ac23cb1a | ||
|
|
c0c678ac0d | ||
|
|
c036b77331 | ||
|
|
7b467807d9 | ||
|
|
2e8b088c59 | ||
|
|
e69a419551 | ||
|
|
a10d0569af | ||
|
|
237b3832bb | ||
|
|
eefa7c8ca8 | ||
|
|
fc0a0615cb | ||
|
|
3a61d68dc1 | ||
|
|
0121195e75 | ||
|
|
0c054bdcf2 | ||
|
|
96f818cb04 | ||
|
|
31ca0419f5 | ||
|
|
78e35222f8 | ||
|
|
c5b854adb3 | ||
|
|
9f374c7716 | ||
|
|
ce8e503faa | ||
|
|
79f914d4b6 | ||
|
|
c4757ee8a9 | ||
|
|
c0212ec836 | ||
|
|
8f0c9afa1a |
7
.env.ci
7
.env.ci
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
8
.github/workflows/build-private.yml
vendored
8
.github/workflows/build-private.yml
vendored
@@ -15,6 +15,8 @@ name: Build - Private
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
@@ -69,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
|
||||
@@ -116,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 }}
|
||||
|
||||
6
.github/workflows/build-public.yml
vendored
6
.github/workflows/build-public.yml
vendored
@@ -15,6 +15,8 @@ name: Build - Public
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
@@ -62,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 }}
|
||||
|
||||
1
.github/workflows/generate-api-docs.yml
vendored
1
.github/workflows/generate-api-docs.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
jobs:
|
||||
api_docs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
|
||||
2
.github/workflows/npm-build.yml
vendored
2
.github/workflows/npm-build.yml
vendored
@@ -4,8 +4,8 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
2
.github/workflows/npm-lint.yml
vendored
2
.github/workflows/npm-lint.yml
vendored
@@ -4,8 +4,8 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
2
.github/workflows/npm-typecheck.yml
vendored
2
.github/workflows/npm-typecheck.yml
vendored
@@ -4,8 +4,8 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
1
.github/workflows/phpstan.yml
vendored
1
.github/workflows/phpstan.yml
vendored
@@ -3,6 +3,7 @@ on: push
|
||||
jobs:
|
||||
phpstan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
1
.github/workflows/phpunit.yml
vendored
1
.github/workflows/phpunit.yml
vendored
@@ -3,6 +3,7 @@ on: push
|
||||
jobs:
|
||||
phpunit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
|
||||
2
.github/workflows/pint.yml
vendored
2
.github/workflows/pint.yml
vendored
@@ -3,6 +3,8 @@ on: push
|
||||
jobs:
|
||||
pint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
6
.github/workflows/playwright.yml
vendored
6
.github/workflows/playwright.yml
vendored
@@ -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'
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -34,3 +34,10 @@ yarn-error.log
|
||||
/_ide_helper.php
|
||||
/.phpstorm.meta.php
|
||||
/.rnd
|
||||
|
||||
/caddy
|
||||
/frankenphp
|
||||
/public/frankenphp-worker.php
|
||||
/data
|
||||
/config/caddy
|
||||
/config/composer
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Enums\Weekday;
|
||||
use App\Events\NewsletterRegistered;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\TimezoneService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -18,6 +19,7 @@ use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Log;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
@@ -55,20 +57,49 @@ class CreateNewUser implements CreatesNewUsers
|
||||
],
|
||||
])->validate();
|
||||
|
||||
$timezone = 'UTC';
|
||||
if (array_key_exists('timezone', $input) && is_string($input['timezone']) && app(TimezoneService::class)->isValid($input['timezone'])) {
|
||||
$timezone = $input['timezone'];
|
||||
$timezone = null;
|
||||
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
|
||||
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
||||
$timezone = $input['timezone'];
|
||||
} else {
|
||||
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
|
||||
}
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($input, $timezone) {
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||
|
||||
$startOfWeek = Weekday::Monday;
|
||||
$currency = null;
|
||||
if ($ipLookupResponse !== null) {
|
||||
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
|
||||
if ($timezone === null) {
|
||||
$timezone = $ipLookupResponse->timezone;
|
||||
}
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($input, $timezone, $startOfWeek, $currency) {
|
||||
return tap(User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
'timezone' => $timezone,
|
||||
'week_start' => Weekday::Monday,
|
||||
]), function (User $user) {
|
||||
$this->createTeam($user);
|
||||
'timezone' => $timezone ?? 'UTC',
|
||||
'week_start' => $startOfWeek,
|
||||
]), function (User $user) use ($currency): void {
|
||||
$organization = new Organization();
|
||||
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
|
||||
$organization->personal_team = true;
|
||||
$organization->currency = $currency ?? 'EUR';
|
||||
$organization->owner()->associate($user);
|
||||
$organization->save();
|
||||
|
||||
$organization->users()->attach(
|
||||
$user, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,24 +110,4 @@ class CreateNewUser implements CreatesNewUsers
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a personal team for the user.
|
||||
*/
|
||||
protected function createTeam(User $user): void
|
||||
{
|
||||
$organization = new Organization();
|
||||
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
|
||||
$organization->personal_team = true;
|
||||
$organization->owner()->associate($user);
|
||||
$organization->save();
|
||||
|
||||
$organization->users()->attach(
|
||||
$user, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Actions\Jetstream;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\UserService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -43,10 +42,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
$organization->users()->attach(
|
||||
$newOrganizationMember, ['role' => $role]
|
||||
);
|
||||
|
||||
if ($role === Role::Owner->value) {
|
||||
app(UserService::class)->changeOwnership($organization, $newOrganizationMember);
|
||||
}
|
||||
});
|
||||
|
||||
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
|
||||
@@ -84,7 +79,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Owner->value,
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
|
||||
@@ -15,6 +15,7 @@ class DeleteOrganization implements DeletesTeams
|
||||
*/
|
||||
public function delete(Organization $organization): void
|
||||
{
|
||||
/** @see ValidateOrganizationDeletion */
|
||||
app(DeletionService::class)->deleteOrganization($organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class DeleteUser implements DeletesUsers
|
||||
{
|
||||
/**
|
||||
* Delete the given user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function delete(User $user): void
|
||||
{
|
||||
|
||||
@@ -4,103 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use Closure;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Exception;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
use Laravel\Jetstream\Mail\TeamInvitation;
|
||||
|
||||
class InviteOrganizationMember implements InvitesTeamMembers
|
||||
{
|
||||
/**
|
||||
* Invite a new team member to the given team.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->has($organization, 'invitations:create')) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
$this->validate($organization, $email, $role);
|
||||
|
||||
InvitingTeamMember::dispatch($organization, $email, $role);
|
||||
|
||||
/** @var OrganizationInvitation $invitation */
|
||||
$invitation = $organization->teamInvitations()->create([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
]);
|
||||
|
||||
Mail::to($email)->send(new TeamInvitation($invitation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the invite member operation.
|
||||
*/
|
||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
||||
{
|
||||
Validator::make([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
], $this->rules($organization))->after(
|
||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||
)->validateWithBag('addTeamMember');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for inviting a team member.
|
||||
*
|
||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
||||
*/
|
||||
protected function rules(Organization $organization): array
|
||||
{
|
||||
return array_filter([
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
|
||||
/** @var Builder<OrganizationInvitation> $builder */
|
||||
return $builder->whereBelongsTo($organization, 'organization');
|
||||
}))->withMessage(__('This user has already been invited to the team.')),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Owner->value,
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the user is not already on the team.
|
||||
*/
|
||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $organization, string $email): Closure
|
||||
{
|
||||
return function ($validator) use ($organization, $email) {
|
||||
$validator->errors()->addIf(
|
||||
$organization->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
__('This user already belongs to the team.')
|
||||
);
|
||||
};
|
||||
throw new MovedToApiException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,50 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Exception;
|
||||
use Laravel\Jetstream\Contracts\RemovesTeamMembers;
|
||||
use Laravel\Jetstream\Events\TeamMemberRemoved;
|
||||
|
||||
class RemoveOrganizationMember implements RemovesTeamMembers
|
||||
{
|
||||
/**
|
||||
* Remove the team member from the given team.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function remove(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
$this->authorize($user, $organization, $teamMember);
|
||||
|
||||
$this->ensureUserDoesNotOwnTeam($teamMember, $organization);
|
||||
|
||||
$organization->removeUser($teamMember);
|
||||
|
||||
TeamMemberRemoved::dispatch($organization, $teamMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize that the user can remove the team member.
|
||||
*/
|
||||
protected function authorize(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
if (! Gate::forUser($user)->check('removeTeamMember', $organization) &&
|
||||
$user->id !== $teamMember->id) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the currently authenticated user does not own the team.
|
||||
*/
|
||||
protected function ensureUserDoesNotOwnTeam(User $teamMember, Organization $organization): void
|
||||
{
|
||||
if ($teamMember->id === $organization->owner->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'team' => [__('You may not leave a team that you created.')],
|
||||
])->errorBag('removeTeamMember');
|
||||
}
|
||||
throw new MovedToApiException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,63 +5,21 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use App\Service\UserService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Events\TeamMemberUpdated;
|
||||
use Exception;
|
||||
|
||||
class UpdateMemberRole
|
||||
{
|
||||
/**
|
||||
* Update the role for the given team member.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->has($organization, 'members:change-role')) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
$user = User::where('id', '=', $userId)->firstOrFail();
|
||||
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
|
||||
if ($member->role === Role::Placeholder->value) {
|
||||
abort(403, 'Cannot update the role of a placeholder member.');
|
||||
}
|
||||
|
||||
Validator::make([
|
||||
'role' => $role,
|
||||
], [
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Owner->value,
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
])->validate();
|
||||
|
||||
DB::transaction(function () use ($organization, $userId, $role, $user) {
|
||||
$organization->users()->updateExistingPivot($userId, [
|
||||
'role' => $role,
|
||||
]);
|
||||
|
||||
if ($role === Role::Owner->value) {
|
||||
app(UserService::class)->changeOwnership($organization, $user);
|
||||
}
|
||||
});
|
||||
|
||||
TeamMemberUpdated::dispatch($organization->fresh(), User::findOrFail($userId));
|
||||
throw new MovedToApiException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class TestJobCommand extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'test:job';
|
||||
protected $signature = 'test:job {--fail}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -30,7 +30,9 @@ class TestJobCommand extends Command
|
||||
public function handle(): int
|
||||
{
|
||||
$user = User::firstOrFail();
|
||||
TestJob::dispatch($user, 'Test job message.');
|
||||
$fail = (bool) $this->option('fail');
|
||||
|
||||
TestJob::dispatch($user, 'Test job message.', $fail);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -11,5 +11,4 @@ enum Role: string
|
||||
case Manager = 'manager';
|
||||
case Employee = 'employee';
|
||||
case Placeholder = 'placeholder';
|
||||
|
||||
}
|
||||
|
||||
10
app/Exceptions/Api/ChangingRoleToPlaceholderIsNotAllowed.php
Normal file
10
app/Exceptions/Api/ChangingRoleToPlaceholderIsNotAllowed.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
|
||||
{
|
||||
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
|
||||
}
|
||||
10
app/Exceptions/Api/OnlyOwnerCanChangeOwnership.php
Normal file
10
app/Exceptions/Api/OnlyOwnerCanChangeOwnership.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OnlyOwnerCanChangeOwnership extends ApiException
|
||||
{
|
||||
public const string KEY = 'only_owner_can_change_ownership';
|
||||
}
|
||||
10
app/Exceptions/Api/OrganizationNeedsAtLeastOneOwner.php
Normal file
10
app/Exceptions/Api/OrganizationNeedsAtLeastOneOwner.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OrganizationNeedsAtLeastOneOwner extends ApiException
|
||||
{
|
||||
public const string KEY = 'organization_needs_at_least_one_owner';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_is_already_member_of_organization';
|
||||
}
|
||||
15
app/Exceptions/MovedToApiException.php
Normal file
15
app/Exceptions/MovedToApiException.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class MovedToApiException extends HttpException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(403, 'Moved to API');
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
||||
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
||||
use App\Http\Requests\V1\Client\ClientIndexRequest;
|
||||
use App\Http\Requests\V1\Client\ClientStoreRequest;
|
||||
use App\Http\Requests\V1\Client\ClientUpdateRequest;
|
||||
use App\Http\Resources\V1\Client\ClientCollection;
|
||||
use App\Http\Resources\V1\Client\ClientResource;
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
@@ -33,14 +35,22 @@ class ClientController extends Controller
|
||||
*
|
||||
* @operationId getClients
|
||||
*/
|
||||
public function index(Organization $organization): ClientCollection
|
||||
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:view');
|
||||
|
||||
$clients = Client::query()
|
||||
$clientsQuery = Client::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
$filterArchived = $request->getFilterArchived();
|
||||
if ($filterArchived === 'true') {
|
||||
$clientsQuery->whereNotNull('archived_at');
|
||||
} elseif ($filterArchived === 'false') {
|
||||
$clientsQuery->whereNull('archived_at');
|
||||
}
|
||||
|
||||
$clients = $clientsQuery->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new ClientCollection($clients);
|
||||
}
|
||||
@@ -52,7 +62,7 @@ class ClientController extends Controller
|
||||
*
|
||||
* @operationId createClient
|
||||
*/
|
||||
public function store(Organization $organization, TagStoreRequest $request): ClientResource
|
||||
public function store(Organization $organization, ClientStoreRequest $request): ClientResource
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:create');
|
||||
|
||||
@@ -71,11 +81,14 @@ class ClientController extends Controller
|
||||
*
|
||||
* @operationId updateClient
|
||||
*/
|
||||
public function update(Organization $organization, Client $client, TagUpdateRequest $request): ClientResource
|
||||
public function update(Organization $organization, Client $client, ClientUpdateRequest $request): ClientResource
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:update', $client);
|
||||
|
||||
$client->name = $request->input('name');
|
||||
if ($request->has('is_archived')) {
|
||||
$client->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||
}
|
||||
$client->save();
|
||||
|
||||
return new ClientResource($client);
|
||||
|
||||
@@ -4,17 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
|
||||
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
|
||||
use App\Http\Resources\V1\Invitation\InvitationCollection;
|
||||
use App\Http\Resources\V1\Invitation\InvitationResource;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Service\InvitationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
use Laravel\Jetstream\Mail\TeamInvitation;
|
||||
|
||||
class InvitationController extends Controller
|
||||
{
|
||||
@@ -49,19 +50,18 @@ class InvitationController extends Controller
|
||||
* Invite a user to the organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
*
|
||||
* @operationId invite
|
||||
*/
|
||||
public function store(Organization $organization, InvitationStoreRequest $request): JsonResponse
|
||||
public function store(Organization $organization, InvitationStoreRequest $request, InvitationService $invitationService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'invitations:create');
|
||||
|
||||
app(InvitesTeamMembers::class)->invite(
|
||||
$this->user(),
|
||||
$organization,
|
||||
$request->input('email'),
|
||||
$request->input('role')
|
||||
);
|
||||
$email = $request->getEmail();
|
||||
$role = $request->getRole();
|
||||
|
||||
$invitationService->inviteUser($organization, $email, $role);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
@@ -77,7 +77,8 @@ class InvitationController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'invitations:resend', $invitation);
|
||||
|
||||
Mail::to($invitation->email)->send(new TeamInvitation($invitation));
|
||||
Mail::to($invitation->email)
|
||||
->queue(new OrganizationInvitationMail($invitation));
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
||||
use App\Http\Requests\V1\Member\MemberUpdateRequest;
|
||||
@@ -17,11 +20,12 @@ use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\InvitationService;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
@@ -56,15 +60,40 @@ class MemberController extends Controller
|
||||
* Update a member of the organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws OrganizationNeedsAtLeastOneOwner
|
||||
* @throws OnlyOwnerCanChangeOwnership
|
||||
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
||||
*
|
||||
* @operationId updateMember
|
||||
*/
|
||||
public function update(Organization $organization, Member $member, MemberUpdateRequest $request): JsonResource
|
||||
public function update(Organization $organization, Member $member, MemberUpdateRequest $request, BillableRateService $billableRateService, MemberService $memberService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'members:update', $member);
|
||||
|
||||
$member->billable_rate = $request->input('billable_rate');
|
||||
$member->role = $request->input('role');
|
||||
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
|
||||
$member->billable_rate = $request->getBillableRate();
|
||||
|
||||
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
||||
}
|
||||
if ($request->has('role') && $member->role !== $request->getRole()->value) {
|
||||
$newRole = $request->getRole();
|
||||
$oldRole = Role::from($member->role);
|
||||
if ($oldRole === Role::Owner) {
|
||||
throw new OrganizationNeedsAtLeastOneOwner();
|
||||
}
|
||||
if ($newRole === Role::Placeholder) {
|
||||
throw new ChangingRoleToPlaceholderIsNotAllowed();
|
||||
}
|
||||
if ($newRole === Role::Owner) {
|
||||
if ($this->hasPermission($organization, 'members:change-ownership')) {
|
||||
$memberService->changeOwnership($organization, $member);
|
||||
} else {
|
||||
throw new OnlyOwnerCanChangeOwnership();
|
||||
}
|
||||
} else {
|
||||
$member->role = $request->getRole()->value;
|
||||
}
|
||||
}
|
||||
$member->save();
|
||||
|
||||
return new MemberResource($member);
|
||||
@@ -104,7 +133,7 @@ class MemberController extends Controller
|
||||
*
|
||||
* @operationId invitePlaceholder
|
||||
*/
|
||||
public function invitePlaceholder(Organization $organization, Member $member, Request $request): JsonResponse
|
||||
public function invitePlaceholder(Organization $organization, Member $member, InvitationService $invitationService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'members:invite-placeholder', $member);
|
||||
$user = $member->user;
|
||||
@@ -113,12 +142,7 @@ class MemberController extends Controller
|
||||
throw new UserNotPlaceholderApiException();
|
||||
}
|
||||
|
||||
app(InvitesTeamMembers::class)->invite(
|
||||
$this->user(),
|
||||
$organization,
|
||||
$user->email,
|
||||
Role::Employee->value,
|
||||
);
|
||||
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1;
|
||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillableRateService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
class OrganizationController extends Controller
|
||||
@@ -32,14 +33,19 @@ class OrganizationController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource
|
||||
public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:update');
|
||||
|
||||
$organization->name = $request->input('name');
|
||||
$oldBillableRate = $organization->billable_rate;
|
||||
$organization->billable_rate = $request->getBillableRate();
|
||||
$organization->save();
|
||||
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
||||
}
|
||||
|
||||
return new OrganizationResource($organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@ use App\Http\Resources\V1\Project\ProjectResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\User;
|
||||
use App\Service\BillableRateService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProjectController extends Controller
|
||||
@@ -50,6 +51,12 @@ class ProjectController extends Controller
|
||||
if (! $canViewAllProjects) {
|
||||
$projectsQuery->visibleByEmployee($user);
|
||||
}
|
||||
$filterArchived = $request->getFilterArchived();
|
||||
if ($filterArchived === 'true') {
|
||||
$projectsQuery->whereNotNull('archived_at');
|
||||
} elseif ($filterArchived === 'false') {
|
||||
$projectsQuery->whereNull('archived_at');
|
||||
}
|
||||
|
||||
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
@@ -101,16 +108,24 @@ class ProjectController extends Controller
|
||||
*
|
||||
* @operationId updateProject
|
||||
*/
|
||||
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request): JsonResource
|
||||
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:update', $project);
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->is_billable = (bool) $request->input('is_billable');
|
||||
if ($request->has('is_archived')) {
|
||||
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||
}
|
||||
$oldBillableRate = $project->billable_rate;
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
$project->client_id = $request->input('client_id');
|
||||
$project->save();
|
||||
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||
}
|
||||
|
||||
return new ProjectResource($project);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Service\BillableRateService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
@@ -87,12 +88,17 @@ class ProjectMemberController extends Controller
|
||||
*
|
||||
* @operationId updateProjectMember
|
||||
*/
|
||||
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request): JsonResource
|
||||
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||
$oldBillableRate = $projectMember->billable_rate;
|
||||
$projectMember->billable_rate = $request->getBillableRate();
|
||||
$projectMember->save();
|
||||
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||
}
|
||||
|
||||
return new ProjectMemberResource($projectMember);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Models\Task;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class TaskController extends Controller
|
||||
{
|
||||
@@ -53,6 +54,12 @@ class TaskController extends Controller
|
||||
if (! $canViewAllTasks) {
|
||||
$query->visibleByEmployee($user);
|
||||
}
|
||||
$doneFilter = $request->getFilterDone();
|
||||
if ($doneFilter === 'true') {
|
||||
$query->whereNotNull('done_at');
|
||||
} elseif ($doneFilter === 'false') {
|
||||
$query->whereNull('done_at');
|
||||
}
|
||||
|
||||
$tasks = $query->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
@@ -89,6 +96,9 @@ class TaskController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:update', $task);
|
||||
$task->name = $request->input('name');
|
||||
if ($request->has('is_done')) {
|
||||
$task->done_at = $request->getIsDone() ? Carbon::now() : null;
|
||||
}
|
||||
$task->save();
|
||||
|
||||
return new TaskResource($task);
|
||||
|
||||
@@ -257,12 +257,17 @@ class TimeEntryController extends Controller
|
||||
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
|
||||
return new TimeEntryResource($timeEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple time entries
|
||||
*
|
||||
* @operationId updateMultipleTimeEntries
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Service\BillingContract;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
use Nwidart\Modules\Facades\Module;
|
||||
@@ -38,8 +39,20 @@ class HandleInertiaRequests extends Middleware
|
||||
*/
|
||||
public function share(Request $request): array
|
||||
{
|
||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||
$billing = null;
|
||||
if ($hasBilling) {
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
}
|
||||
|
||||
$currentOrganization = $request->user()?->currentTeam;
|
||||
|
||||
return array_merge(parent::share($request), [
|
||||
'has_billing_extension' => Module::has('Billing'),
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'billing' => $billing !== null ? [
|
||||
'has_subscription' => $currentOrganization !== null ? $billing->hasSubscription($currentOrganization) : null,
|
||||
] : null,
|
||||
'flash' => [
|
||||
'message' => fn () => $request->session()->get('message'),
|
||||
],
|
||||
|
||||
35
app/Http/Requests/V1/Client/ClientIndexRequest.php
Normal file
35
app/Http/Requests/V1/Client/ClientIndexRequest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Client;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ClientIndexRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
],
|
||||
'archived' => [
|
||||
'string',
|
||||
'in:true,false,all',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getFilterArchived(): string
|
||||
{
|
||||
return $this->input('archived', 'false');
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/V1/Client/ClientStoreRequest.php
Normal file
39
app/Http/Requests/V1/Client/ClientStoreRequest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Client;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class ClientStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->withCustomTranslation('validation.client_name_already_exists'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/V1/Client/ClientUpdateRequest.php
Normal file
51
app/Http/Requests/V1/Client/ClientUpdateRequest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Client;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
* @property Client|null $client Client from model binding
|
||||
*/
|
||||
class ClientUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// Name of the client
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'),
|
||||
],
|
||||
'is_archived' => [
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getIsArchived(): bool
|
||||
{
|
||||
assert($this->has('is_archived'));
|
||||
|
||||
return (bool) $this->input('is_archived');
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ namespace App\Http\Requests\V1\Invitation;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
@@ -26,13 +29,27 @@ class InvitationStoreRequest extends FormRequest
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
|
||||
/** @var Builder<OrganizationInvitation> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->withCustomTranslation('validation.invitation_already_exists'),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
// TODO: placeholder role should not be allowed
|
||||
Rule::enum(Role::class),
|
||||
Rule::enum(Role::class)
|
||||
->except([Role::Owner, Role::Placeholder]),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getRole(): Role
|
||||
{
|
||||
return Role::from($this->input('role'));
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->input('email');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,17 +23,15 @@ class MemberUpdateRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'role' => [
|
||||
'string',
|
||||
Rule::enum(Role::class),
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
// TODO: placeholder role should not be allowed
|
||||
Rule::enum(Role::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -43,4 +41,9 @@ class MemberUpdateRequest extends FormRequest
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||
}
|
||||
|
||||
public function getRole(): Role
|
||||
{
|
||||
return Role::from($this->input('role'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ class ProjectIndexRequest extends FormRequest
|
||||
'integer',
|
||||
'min:1',
|
||||
],
|
||||
'archived' => [
|
||||
'string',
|
||||
'in:true,false,all',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getFilterArchived(): string
|
||||
{
|
||||
return $this->input('archived', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ namespace App\Http\Requests\V1\Project;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Rules\ColorRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
@@ -26,11 +28,14 @@ class ProjectStoreRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->withCustomTranslation('validation.project_name_already_exists'),
|
||||
],
|
||||
'color' => [
|
||||
'required',
|
||||
|
||||
@@ -6,14 +6,17 @@ namespace App\Http\Requests\V1\Project;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Rules\ColorRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
* @property Project|null $project Project from model binding
|
||||
*/
|
||||
class ProjectUpdateRequest extends FormRequest
|
||||
{
|
||||
@@ -26,10 +29,13 @@ class ProjectUpdateRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
|
||||
],
|
||||
'color' => [
|
||||
'required',
|
||||
@@ -41,10 +47,8 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'required',
|
||||
'boolean',
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'is_archived' => [
|
||||
'boolean',
|
||||
],
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
@@ -53,9 +57,21 @@ class ProjectUpdateRequest extends FormRequest
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getIsArchived(): bool
|
||||
{
|
||||
assert($this->has('is_archived'));
|
||||
|
||||
return (bool) $this->input('is_archived');
|
||||
}
|
||||
|
||||
public function getBillableRate(): ?int
|
||||
{
|
||||
$input = $this->input('billable_rate');
|
||||
|
||||
@@ -4,9 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Tag;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class TagStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
@@ -18,11 +25,14 @@ class TagStoreRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->withCustomTranslation('validation.tag_name_already_exists'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Tag;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
* @property Tag|null $tag Tag from model binding
|
||||
*/
|
||||
class TagUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
@@ -18,11 +26,14 @@ class TagUpdateRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -39,6 +39,15 @@ class TaskIndexRequest extends FormRequest
|
||||
return $builder;
|
||||
}),
|
||||
],
|
||||
'done' => [
|
||||
'string',
|
||||
'in:true,false,all',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getFilterDone(): string
|
||||
{
|
||||
return $this->input('done', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ namespace App\Http\Requests\V1\Task;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
@@ -25,11 +27,14 @@ class TaskStoreRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->where('project_id', '=', $this->input('project_id'));
|
||||
}))->withCustomTranslation('validation.task_name_already_exists'),
|
||||
],
|
||||
'project_id' => [
|
||||
'required',
|
||||
|
||||
@@ -5,11 +5,15 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\Task;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
* @property Task|null $task Task from model binding
|
||||
*/
|
||||
class TaskUpdateRequest extends FormRequest
|
||||
{
|
||||
@@ -22,12 +26,25 @@ class TaskUpdateRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
(new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->where('project_id', '=', $this->task->project_id);
|
||||
}))->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'),
|
||||
],
|
||||
'is_done' => [
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getIsDone(): bool
|
||||
{
|
||||
assert($this->has('is_done'));
|
||||
|
||||
return $this->boolean('is_done');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ class ClientResource extends BaseResource
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $is_archived Whether the client is archived */
|
||||
'is_archived' => $this->resource->is_archived,
|
||||
/** @var string $created_at When the tag was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string $updated_at When the tag was last updated */
|
||||
|
||||
@@ -29,6 +29,8 @@ class ProjectResource extends BaseResource
|
||||
'color' => $this->resource->color,
|
||||
/** @var string|null $client_id ID of client */
|
||||
'client_id' => $this->resource->client_id,
|
||||
/** @var bool $is_archived Whether the client is archived */
|
||||
'is_archived' => $this->resource->is_archived,
|
||||
/** @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 */
|
||||
|
||||
@@ -26,6 +26,8 @@ class TaskResource extends BaseResource
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $is_done Whether the task is done */
|
||||
'is_done' => $this->resource->is_done,
|
||||
/** @var string $project_id ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
/** @var string $created_at When the tag was created */
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Jobs\Test;
|
||||
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -23,22 +24,30 @@ class TestJob implements ShouldQueue
|
||||
|
||||
private string $message;
|
||||
|
||||
private bool $fail;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(User $user, string $message)
|
||||
public function __construct(User $user, string $message, bool $fail = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->message = $message;
|
||||
$this->fail = $fail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
Log::debug('TestJob: '.$this->message, [
|
||||
'user' => $this->user->getKey(),
|
||||
]);
|
||||
if ($this->fail) {
|
||||
throw new Exception('TestJob failed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Mail/OrganizationInvitationMail.php
Normal file
40
app/Mail/OrganizationInvitationMail.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class OrganizationInvitationMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public OrganizationInvitation $invitation;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(OrganizationInvitation $invitation)
|
||||
{
|
||||
$this->invitation = $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.organization-invitation', [
|
||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
||||
'invitation' => $this->invitation,
|
||||
]),
|
||||
])->subject(__('Organization Invitation'));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ClientFactory;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -16,6 +17,8 @@ use Illuminate\Support\Carbon;
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string $organization_id
|
||||
* @property-read bool $is_archived
|
||||
* @property Carbon|null $archived_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Organization $organization
|
||||
@@ -51,4 +54,14 @@ class Client extends Model
|
||||
{
|
||||
return $this->hasMany(Project::class, 'client_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Attribute<bool, never>
|
||||
*/
|
||||
protected function isArchived(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\MemberFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Laravel\Jetstream\Membership as JetstreamMembership;
|
||||
|
||||
/**
|
||||
@@ -50,4 +51,12 @@ class Member extends JetstreamMembership
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProjectMember>
|
||||
*/
|
||||
public function projectMembers(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectMember::class, 'member_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\OrganizationFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Jetstream\Events\TeamCreated;
|
||||
use Laravel\Jetstream\Events\TeamDeleted;
|
||||
use Laravel\Jetstream\Events\TeamUpdated;
|
||||
@@ -123,4 +125,21 @@ class Organization extends JetstreamTeam
|
||||
return $this->users()
|
||||
->where('is_placeholder', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method prevents an unhandled exception when the ID is not a UUID.
|
||||
* Normally this can be fixed with a route pattern, but Jetstream does not use route model binding.
|
||||
*
|
||||
* @param array<string> $columns
|
||||
*/
|
||||
public function findOrFail(string $id, array $columns = ['*']): \Laravel\Jetstream\Team
|
||||
{
|
||||
if (! Str::isUuid($id)) {
|
||||
throw (new ModelNotFoundException)->setModel(
|
||||
self::class, $id
|
||||
);
|
||||
}
|
||||
|
||||
return parent::findOrFail($id, $columns);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ namespace App\Models;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ProjectFactory;
|
||||
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\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -21,6 +23,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
* @property string $client_id
|
||||
* @property int|null $billable_rate
|
||||
* @property bool $is_billable
|
||||
* @property-read bool $is_archived
|
||||
* @property Carbon|null $archived_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Organization $organization
|
||||
* @property-read Client|null $client
|
||||
* @property-read Collection<int, Task> $tasks
|
||||
@@ -105,4 +111,14 @@ class Project extends Model
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Attribute<bool, never>
|
||||
*/
|
||||
protected function isArchived(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\TaskFactory;
|
||||
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\Model;
|
||||
@@ -19,11 +20,13 @@ use Illuminate\Support\Carbon;
|
||||
* @property string $name
|
||||
* @property string $project_id
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $done_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Project $project
|
||||
* @property-read Organization $organization
|
||||
* @property-read Collection<int, TimeEntry> $timeEntries
|
||||
* @property-read bool $is_done
|
||||
*
|
||||
* @method static TaskFactory factory()
|
||||
*/
|
||||
@@ -76,4 +79,14 @@ class Task extends Model
|
||||
return $builder->visibleByEmployee($user);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Attribute<bool, never>
|
||||
*/
|
||||
public function isDone(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value, array $attributes) => isset($attributes['done_at']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class OrganizationPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->ownsTeam($organization);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +82,8 @@ class OrganizationPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->ownsTeam($organization);
|
||||
// Note: since this policy is only used for jetstream endpoints, we can return false here
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +95,8 @@ class OrganizationPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->ownsTeam($organization);
|
||||
// Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,9 @@ use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\IpLookup\NoIpLookupService;
|
||||
use App\Service\PermissionStore;
|
||||
use Dedoc\Scramble\Scramble;
|
||||
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||
@@ -85,6 +88,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
return new PermissionStore();
|
||||
});
|
||||
|
||||
// Extensions
|
||||
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
||||
$this->app->bind(BillingContract::class);
|
||||
|
||||
Route::model('member', Member::class);
|
||||
Route::model('invitation', OrganizationInvitation::class);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use App\Service\TimezoneService;
|
||||
use Brick\Money\Currency;
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Fortify;
|
||||
@@ -66,6 +67,9 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||
]);
|
||||
});
|
||||
Gate::define('removeTeamMember', function (User $user, Organization $team) {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +120,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'invitations:remove',
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
'members:change-role',
|
||||
'members:change-ownership',
|
||||
'members:update',
|
||||
'members:delete',
|
||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
||||
@@ -160,6 +164,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'invitations:resend',
|
||||
'invitations:remove',
|
||||
'members:view',
|
||||
'members:update',
|
||||
'members:invite-placeholder',
|
||||
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
||||
|
||||
|
||||
@@ -9,9 +9,75 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BillableRateService
|
||||
{
|
||||
public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
|
||||
{
|
||||
TimeEntry::query()
|
||||
->where('billable', '=', true)
|
||||
->where('member_id', '=', $projectMember->member_id)
|
||||
->where('project_id', '=', $projectMember->project_id)
|
||||
->update(['billable_rate' => $projectMember->billable_rate]);
|
||||
}
|
||||
|
||||
public function updateTimeEntriesBillableRateForProject(Project $project): void
|
||||
{
|
||||
TimeEntry::query()
|
||||
->where('billable', '=', true)
|
||||
->where('organization_id', '=', $project->organization_id)
|
||||
->whereBelongsTo($project, 'project')
|
||||
->whereDoesntHave('member', function (Builder $query) use ($project) {
|
||||
/** @var Builder<Member> $query */
|
||||
$query->whereHas('projectMembers', function (Builder $query) use ($project) {
|
||||
/** @var Builder<ProjectMember> $query */
|
||||
$query->whereBelongsTo($project, 'project')
|
||||
->whereNotNull('billable_rate');
|
||||
});
|
||||
})
|
||||
->update(['billable_rate' => $project->billable_rate]);
|
||||
}
|
||||
|
||||
public function updateTimeEntriesBillableRateForMember(Member $member): void
|
||||
{
|
||||
TimeEntry::query()
|
||||
->where('billable', '=', true)
|
||||
->where('organization_id', '=', $member->organization_id)
|
||||
->where('member_id', '=', $member->getKey())
|
||||
->whereDoesntHave('project', function (Builder $builder) use ($member): void {
|
||||
/** @var Builder<Project> $builder */
|
||||
$builder->whereNotNull('billable_rate')
|
||||
->orWhereHas('members', function (Builder $builder) use ($member): void {
|
||||
/** @var Builder<ProjectMember> $builder */
|
||||
$builder->whereNotNull('billable_rate')
|
||||
->where('member_id', '=', $member->getKey());
|
||||
});
|
||||
})
|
||||
->update(['billable_rate' => $member->billable_rate]);
|
||||
}
|
||||
|
||||
public function updateTimeEntriesBillableRateForOrganization(Organization $organization): void
|
||||
{
|
||||
TimeEntry::query()
|
||||
->where('billable', '=', true)
|
||||
->where('organization_id', '=', $organization->getKey())
|
||||
->whereDoesntHave('member', function (Builder $builder) {
|
||||
/** @var Builder<Member> $builder */
|
||||
$builder->whereNotNull('billable_rate');
|
||||
})
|
||||
->whereDoesntHave('project', function (Builder $builder): void {
|
||||
/** @var Builder<Project> $builder */
|
||||
$builder->whereNotNull('billable_rate')
|
||||
->orWhereHas('members', function (Builder $builder): void {
|
||||
/** @var Builder<ProjectMember> $builder */
|
||||
$builder->whereNotNull('billable_rate')
|
||||
->whereRaw('member_id = time_entries.member_id');
|
||||
});
|
||||
})
|
||||
->update(['billable_rate' => $organization->billable_rate]);
|
||||
}
|
||||
|
||||
public function getBillableRateForTimeEntryWithGivenRelations(TimeEntry $timeEntry, ?ProjectMember $projectMember, ?Project $project, ?Member $member, ?Organization $organization): ?int
|
||||
{
|
||||
if (! $timeEntry->billable) {
|
||||
|
||||
15
app/Service/BillingContract.php
Normal file
15
app/Service/BillingContract.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\Organization;
|
||||
|
||||
class BillingContract
|
||||
{
|
||||
public function hasSubscription(Organization $organization): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,12 @@ class DeletionService
|
||||
{
|
||||
private UserService $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
private MemberService $memberService;
|
||||
|
||||
public function __construct(UserService $userService, MemberService $memberService)
|
||||
{
|
||||
$this->userService = $userService;
|
||||
$this->memberService = $memberService;
|
||||
}
|
||||
|
||||
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
|
||||
@@ -145,7 +148,7 @@ class DeletionService
|
||||
if ($member->role === Role::Owner->value) {
|
||||
$this->deleteOrganization($member->organization, false, $user);
|
||||
} else {
|
||||
$this->userService->makeMemberToPlaceholder($member);
|
||||
$this->memberService->makeMemberToPlaceholder($member);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
43
app/Service/InvitationService.php
Normal file
43
app/Service/InvitationService.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
|
||||
class InvitationService
|
||||
{
|
||||
/**
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
*/
|
||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||
{
|
||||
if (Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereRelation('user', 'email', '=', $email)
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->exists()) {
|
||||
throw new UserIsAlreadyMemberOfOrganizationApiException();
|
||||
}
|
||||
|
||||
InvitingTeamMember::dispatch($organization, $email, $role->value);
|
||||
|
||||
$invitation = new OrganizationInvitation();
|
||||
$invitation->email = $email;
|
||||
$invitation->role = $role->value;
|
||||
$invitation->organization()->associate($organization);
|
||||
$invitation->save();
|
||||
|
||||
Mail::to($email)->queue(new OrganizationInvitationMail($invitation));
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
}
|
||||
23
app/Service/IpLookup/IpLookupResponseDto.php
Normal file
23
app/Service/IpLookup/IpLookupResponseDto.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\IpLookup;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
|
||||
class IpLookupResponseDto
|
||||
{
|
||||
public ?string $timezone;
|
||||
|
||||
public ?Weekday $startOfWeek;
|
||||
|
||||
public ?string $currency;
|
||||
|
||||
public function __construct(?string $timezone, ?Weekday $startOfWeek, ?string $currency)
|
||||
{
|
||||
$this->timezone = $timezone;
|
||||
$this->startOfWeek = $startOfWeek;
|
||||
$this->currency = $currency;
|
||||
}
|
||||
}
|
||||
10
app/Service/IpLookup/IpLookupServiceContract.php
Normal file
10
app/Service/IpLookup/IpLookupServiceContract.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\IpLookup;
|
||||
|
||||
interface IpLookupServiceContract
|
||||
{
|
||||
public function lookup(string $ip): ?IpLookupResponseDto;
|
||||
}
|
||||
13
app/Service/IpLookup/NoIpLookupService.php
Normal file
13
app/Service/IpLookup/NoIpLookupService.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\IpLookup;
|
||||
|
||||
class NoIpLookupService implements IpLookupServiceContract
|
||||
{
|
||||
public function lookup(string $ip): ?IpLookupResponseDto
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
61
app/Service/MemberService.php
Normal file
61
app/Service/MemberService.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class MemberService
|
||||
{
|
||||
private UserService $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
{
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the ownership of an organization to a new user.
|
||||
* The previous owner will be demoted to an admin.
|
||||
*/
|
||||
public function changeOwnership(Organization $organization, Member $newOwner): void
|
||||
{
|
||||
$organization->update([
|
||||
'user_id' => $newOwner->user_id,
|
||||
]);
|
||||
if ($newOwner->organization_id !== $organization->getKey()) {
|
||||
throw new InvalidArgumentException('Member is not part of the organization');
|
||||
}
|
||||
$newOwner->role = Role::Owner->value;
|
||||
$newOwner->save();
|
||||
$oldOwners = Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->where('role', '=', Role::Owner->value)
|
||||
->where('id', '!=', $newOwner->getKey())
|
||||
->get();
|
||||
foreach ($oldOwners as $oldOwner) {
|
||||
$oldOwner->role = Role::Admin->value;
|
||||
$oldOwner->save();
|
||||
}
|
||||
}
|
||||
|
||||
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->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
|
||||
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ class UserService
|
||||
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
|
||||
}
|
||||
|
||||
private function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
|
||||
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
|
||||
{
|
||||
// Time entries
|
||||
TimeEntry::query()
|
||||
@@ -52,21 +52,6 @@ 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) {
|
||||
|
||||
@@ -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": {
|
||||
@@ -102,6 +102,9 @@
|
||||
"ide-helper": [
|
||||
"@php artisan ide-helper:generate",
|
||||
"@php artisan ide-helper:meta"
|
||||
],
|
||||
"refresh-schema-dump": [
|
||||
"@php artisan schema:dump --database=\"pgsql_test\""
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
@@ -115,10 +118,6 @@
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/korridor/scramble"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/korridor/laravel-modules"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
|
||||
660
composer.lock
generated
660
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -68,7 +68,7 @@ return [
|
||||
'servers' => [
|
||||
'Production' => 'https://app.solidtime.io/api',
|
||||
'Staging' => 'https://app.staging.solidtime.io/api',
|
||||
'Local' => 'https://soldtime.test/api',
|
||||
'Local' => 'https://solidtime.test/api',
|
||||
],
|
||||
|
||||
'middleware' => [
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
|
||||
/*
|
||||
|
||||
@@ -22,6 +22,7 @@ class ClientFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->company(),
|
||||
'archived_at' => null,
|
||||
'organization_id' => Organization::factory(),
|
||||
];
|
||||
}
|
||||
@@ -43,4 +44,13 @@ class ClientFactory extends Factory
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function archived(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
return [
|
||||
'archived_at' => $this->faker->dateTime(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ class MemberFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'billable_rate' => null,
|
||||
'role' => Role::Employee,
|
||||
'organization_id' => Organization::factory(),
|
||||
'user_id' => User::factory(),
|
||||
@@ -68,6 +69,20 @@ class MemberFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
public function billableRate(?int $billableRate): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'billable_rate' => $billableRate,
|
||||
]);
|
||||
}
|
||||
|
||||
public function withBillableRate(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
||||
]);
|
||||
}
|
||||
|
||||
public function attachToOrganization(Organization $organization, array $pivot = []): static
|
||||
{
|
||||
return $this->afterCreating(function (User $user) use ($organization, $pivot) {
|
||||
|
||||
@@ -23,12 +23,26 @@ class OrganizationFactory extends Factory
|
||||
return [
|
||||
'name' => $this->faker->unique()->company(),
|
||||
'currency' => $this->faker->currencyCode(),
|
||||
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
||||
'billable_rate' => null,
|
||||
'user_id' => User::factory(),
|
||||
'personal_team' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function billableRate(?int $billableRate): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'billable_rate' => $billableRate,
|
||||
]);
|
||||
}
|
||||
|
||||
public function withBillableRate(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
||||
]);
|
||||
}
|
||||
|
||||
public function withOwner(?User $owner = null): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
||||
@@ -30,6 +30,7 @@ class ProjectFactory extends Factory
|
||||
'is_billable' => false,
|
||||
'billable_rate' => null,
|
||||
'is_public' => false,
|
||||
'archived_at' => null,
|
||||
'client_id' => null,
|
||||
'organization_id' => Organization::factory(),
|
||||
];
|
||||
@@ -45,6 +46,15 @@ class ProjectFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
public function archived(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
return [
|
||||
'archived_at' => $this->faker->dateTime(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function forOrganization(Organization $organization): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($organization): array {
|
||||
|
||||
@@ -25,6 +25,7 @@ class TaskFactory extends Factory
|
||||
'name' => $this->faker->word(),
|
||||
'project_id' => Project::factory(),
|
||||
'organization_id' => Organization::factory(),
|
||||
'done_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -37,6 +38,15 @@ class TaskFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
public function isDone(): self
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
return [
|
||||
'done_at' => $this->faker->dateTime('now', 'UTC'),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function forOrganization(Organization $organization): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($organization) {
|
||||
|
||||
@@ -40,9 +40,29 @@ class TimeEntryFactory extends Factory
|
||||
'task_id' => null,
|
||||
'project_id' => null,
|
||||
'organization_id' => Organization::factory(),
|
||||
'billable_rate' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function notBillable(): self
|
||||
{
|
||||
return $this->state(function (array $attributes): array {
|
||||
return [
|
||||
'billable' => false,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function billableRate(int $billableRate): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($billableRate): array {
|
||||
return [
|
||||
'billable' => true,
|
||||
'billable_rate' => $billableRate,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function withTask(Organization $organization): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use (&$organization): array {
|
||||
|
||||
@@ -116,6 +116,7 @@ class UserFactory extends Factory
|
||||
->when(is_callable($callback), $callback)
|
||||
->create();
|
||||
|
||||
$organization->owner()->associate($user);
|
||||
$organization->users()->attach($user, ['role' => Role::Owner->value]);
|
||||
$user->currentTeam()->associate($organization);
|
||||
$user->save();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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('projects', function (Blueprint $table): void {
|
||||
$table->dateTime('archived_at')->nullable();
|
||||
});
|
||||
Schema::table('clients', function (Blueprint $table): void {
|
||||
$table->dateTime('archived_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropColumn('archived_at');
|
||||
});
|
||||
Schema::table('clients', function (Blueprint $table): void {
|
||||
$table->dropColumn('archived_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('tasks', function (Blueprint $table): void {
|
||||
$table->dateTime('done_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tasks', function (Blueprint $table): void {
|
||||
$table->dropColumn('done_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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
|
||||
{
|
||||
DB::table('failed_jobs')->truncate();
|
||||
Schema::table('failed_jobs', function (Blueprint $table): void {
|
||||
$table->dropColumn('id');
|
||||
});
|
||||
Schema::table('failed_jobs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('failed_jobs')->truncate();
|
||||
Schema::table('failed_jobs', function (Blueprint $table): void {
|
||||
$table->dropColumn('id');
|
||||
});
|
||||
Schema::table('failed_jobs', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
--
|
||||
|
||||
-- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2)
|
||||
-- Dumped by pg_dump version 15.6 (Ubuntu 15.6-1.pgdg22.04+1)
|
||||
-- Dumped by pg_dump version 15.7 (Ubuntu 15.7-1.pgdg22.04+1)
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
@@ -947,7 +947,7 @@ ALTER TABLE ONLY public.clients
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.organization_invitations
|
||||
ADD CONSTRAINT organization_invitations_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE;
|
||||
ADD CONSTRAINT organization_invitations_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT;
|
||||
|
||||
|
||||
--
|
||||
@@ -955,7 +955,7 @@ ALTER TABLE ONLY public.organization_invitations
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.project_members
|
||||
ADD CONSTRAINT project_members_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
ADD CONSTRAINT project_members_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE RESTRICT;
|
||||
|
||||
|
||||
--
|
||||
@@ -1019,7 +1019,7 @@ ALTER TABLE ONLY public.tasks
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.time_entries
|
||||
ADD CONSTRAINT time_entries_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
ADD CONSTRAINT time_entries_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE RESTRICT;
|
||||
|
||||
|
||||
--
|
||||
@@ -1027,7 +1027,7 @@ ALTER TABLE ONLY public.time_entries
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.time_entries
|
||||
ADD CONSTRAINT time_entries_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
ADD CONSTRAINT time_entries_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE RESTRICT;
|
||||
|
||||
|
||||
--
|
||||
@@ -1071,7 +1071,7 @@ ALTER TABLE ONLY public.time_entries
|
||||
--
|
||||
|
||||
-- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2)
|
||||
-- Dumped by pg_dump version 15.6 (Ubuntu 15.6-1.pgdg22.04+1)
|
||||
-- Dumped by pg_dump version 15.7 (Ubuntu 15.7-1.pgdg22.04+1)
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
@@ -1097,30 +1097,33 @@ COPY public.migrations (id, migration, batch) FROM stdin;
|
||||
6 2016_06_01_000003_create_oauth_refresh_tokens_table 1
|
||||
7 2016_06_01_000004_create_oauth_clients_table 1
|
||||
8 2016_06_01_000005_create_oauth_personal_access_clients_table 1
|
||||
9 2019_05_03_000001_create_customers_table 1
|
||||
10 2019_05_03_000002_create_subscriptions_table 1
|
||||
11 2019_05_03_000003_create_subscription_items_table 1
|
||||
12 2019_05_03_000004_create_transactions_table 1
|
||||
13 2019_08_19_000000_create_failed_jobs_table 1
|
||||
14 2019_12_14_000001_create_personal_access_tokens_table 1
|
||||
15 2020_05_21_100000_create_organizations_table 1
|
||||
16 2020_05_21_200000_create_organization_user_table 1
|
||||
17 2020_05_21_300000_create_organization_invitations_table 1
|
||||
18 2024_01_16_161030_create_sessions_table 1
|
||||
19 2024_01_20_110218_create_clients_table 1
|
||||
20 2024_01_20_110439_create_projects_table 1
|
||||
21 2024_01_20_110444_create_tasks_table 1
|
||||
22 2024_01_20_110452_create_tags_table 1
|
||||
23 2024_01_20_110837_create_time_entries_table 1
|
||||
24 2024_03_26_171253_create_project_members_table 1
|
||||
25 2024_04_11_150130_create_jobs_table 1
|
||||
26 2024_04_12_095010_create_cache_table 1
|
||||
27 2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table 1
|
||||
28 2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table 1
|
||||
29 2024_05_13_171020_rename_table_organization_user_to_members 1
|
||||
31 2024_05_22_151226_add_client_id_to_time_entries_table 2
|
||||
36 2024_05_30_175801_add_is_billable_column_to_projects_table 3
|
||||
37 2024_05_30_175825_add_is_imported_column_to_time_entries_table 3
|
||||
9 2018_08_08_100000_create_telescope_entries_table 1
|
||||
10 2019_05_03_000001_create_customers_table 1
|
||||
11 2019_05_03_000002_create_subscriptions_table 1
|
||||
12 2019_05_03_000003_create_subscription_items_table 1
|
||||
13 2019_05_03_000004_create_transactions_table 1
|
||||
14 2019_08_19_000000_create_failed_jobs_table 1
|
||||
15 2019_12_14_000001_create_personal_access_tokens_table 1
|
||||
16 2020_05_21_100000_create_organizations_table 1
|
||||
17 2020_05_21_200000_create_organization_user_table 1
|
||||
18 2020_05_21_300000_create_organization_invitations_table 1
|
||||
19 2024_01_16_161030_create_sessions_table 1
|
||||
20 2024_01_20_110218_create_clients_table 1
|
||||
21 2024_01_20_110439_create_projects_table 1
|
||||
22 2024_01_20_110444_create_tasks_table 1
|
||||
23 2024_01_20_110452_create_tags_table 1
|
||||
24 2024_01_20_110837_create_time_entries_table 1
|
||||
25 2024_03_26_171253_create_project_members_table 1
|
||||
26 2024_04_11_150130_create_jobs_table 1
|
||||
27 2024_04_12_095010_create_cache_table 1
|
||||
28 2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table 1
|
||||
29 2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table 1
|
||||
30 2024_05_13_171020_rename_table_organization_user_to_members 1
|
||||
31 2024_05_22_151226_add_client_id_to_time_entries_table 1
|
||||
32 2024_05_30_175801_add_is_billable_column_to_projects_table 1
|
||||
33 2024_05_30_175825_add_is_imported_column_to_time_entries_table 1
|
||||
34 2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete 1
|
||||
35 2024_06_10_161831_reset_billable_rates_with_zero_as_value 1
|
||||
\.
|
||||
|
||||
|
||||
@@ -1128,7 +1131,7 @@ COPY public.migrations (id, migration, batch) FROM stdin;
|
||||
-- Name: migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.migrations_id_seq', 37, true);
|
||||
SELECT pg_catalog.setval('public.migrations_id_seq', 35, true);
|
||||
|
||||
|
||||
--
|
||||
@@ -28,7 +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=swoole --watch --host=0.0.0.0 --port=80"
|
||||
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}'
|
||||
@@ -107,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
25
docker/prod/deployment/octane/RoadRunner/.rr.prod.yaml
Normal file
25
docker/prod/deployment/octane/RoadRunner/.rr.prod.yaml
Normal 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
|
||||
@@ -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
|
||||
50
docker/prod/deployment/octane/Swoole/supervisord.swoole.conf
Normal file
50
docker/prod/deployment/octane/Swoole/supervisord.swoole.conf
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
container_mode=${CONTAINER_MODE:-http}
|
||||
container_mode=${CONTAINER_MODE:-"http"}
|
||||
octane_server=${OCTANE_SERVER}
|
||||
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
|
||||
echo "Container mode: $container_mode"
|
||||
@@ -9,7 +9,7 @@ echo "Container mode: $container_mode"
|
||||
initialStuff() {
|
||||
if [ ${auto_db_migrate} = "true" ]; then
|
||||
echo "Auto database migration enabled."
|
||||
php artisan migrate --force
|
||||
php artisan migrate --isolated --force
|
||||
fi
|
||||
php artisan optimize:clear; \
|
||||
php artisan event:cache; \
|
||||
@@ -22,7 +22,16 @@ if [ "$1" != "" ]; then
|
||||
elif [ ${container_mode} = "http" ]; then
|
||||
echo "Octane Server: $octane_server"
|
||||
initialStuff
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
|
||||
if [ ${octane_server} = "frankenphp" ]; then
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
|
||||
elif [ ${octane_server} = "swoole" ]; then
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
|
||||
elif [ ${octane_server} = "roadrunner" ]; then
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
|
||||
else
|
||||
echo "Invalid Octane server supplied."
|
||||
exit 1
|
||||
fi
|
||||
elif [ ${container_mode} = "horizon" ]; then
|
||||
initialStuff
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.horizon.conf
|
||||
|
||||
14
docker/prod/deployment/supervisord.conf
Normal file
14
docker/prod/deployment/supervisord.conf
Normal file
@@ -0,0 +1,14 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=%(ENV_USER)s
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[unix_http_server]
|
||||
file=/var/run/supervisor.sock
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///var/run/supervisor.sock
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
|
||||
@@ -1,9 +1,3 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=%(ENV_USER)s
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan horizon
|
||||
@@ -15,3 +9,6 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopwaitsecs=3600
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
@@ -1,9 +1,3 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=%(ENV_USER)s
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:scheduler]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=supercronic -overlapping /etc/supercronic/laravel
|
||||
@@ -21,7 +15,12 @@ command=php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
startretries=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
@@ -1,9 +1,3 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=%(ENV_USER)s
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:worker]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=%(ENV_WORKER_COMMAND)s
|
||||
@@ -14,3 +8,6 @@ stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user