Compare commits

...

67 Commits

Author SHA1 Message Date
Gregor Vostrak
85aa182fce fix billable default when starting time entry from recently tracked tasks 2024-10-01 13:10:50 +02:00
Gregor Vostrak
6c4256b27d add project member role selection to create and update modals, fixes ST-446 2024-10-01 01:13:29 +02:00
Constantin Graf
a6e5d375a1 Add roles for project members; Restrict access to sensitive project values 2024-09-30 19:03:14 +02:00
Gregor Vostrak
32c7e55a15 add Upgrade Info Modal, fix hardcoded premium flag 2024-09-30 14:52:18 +02:00
Gregor Vostrak
084647c2a6 add project edit button to project show page and billing rate info, fixes ST-236 2024-09-30 14:19:47 +02:00
Gregor Vostrak
469f128604 fix project name column overflow on some screen sizes with long project names 2024-09-30 14:19:47 +02:00
Gregor Vostrak
c9c221de62 improve focus handling in time tracker component, improve focus-visible state for timetracker start and stop button 2024-09-30 14:19:47 +02:00
Gregor Vostrak
878bbd359d cleanup dayjs abstraction usage and useCurrentTimeEntry api for starting and stopping time entries 2024-09-30 14:19:47 +02:00
Gregor Vostrak
a6528102fe add estimated project and tasks frontend 2024-09-30 14:19:47 +02:00
Constantin Graf
bff766d363 Add spend_time to projects and tasks 2024-09-30 14:19:47 +02:00
Constantin Graf
2e8da98287 Added php-cs-fixer rule void_return 2024-09-30 14:19:47 +02:00
Constantin Graf
a820d8540f Added time estimates for projects and tasks, fixes ST-283 2024-09-30 14:19:47 +02:00
Constantin Graf
78ea8a673b Fixed timezone problem in unit tests 2024-09-30 11:02:11 +02:00
Gregor Vostrak
8b50f33cc9 chore: remove unnecessary startLiveTimer call in current time entry init 2024-09-26 01:02:34 +02:00
Gregor Vostrak
014bffe86d display the number of projects in a separate column in the clients table 2024-09-26 00:59:51 +02:00
Gregor Vostrak
2dbde63043 clear client name input on client create submit, fixes #189 2024-09-25 14:51:25 +02:00
Gregor Vostrak
876a41cb2a fix client page header design bug 2024-09-23 12:54:09 +02:00
Gregor Vostrak
1036502e49 remove wrong character from billing banner 2024-09-20 23:40:02 +02:00
Gregor Vostrak
5bf4dc79c2 hide explanation text for billing banner on mobile view 2024-09-20 12:59:09 +02:00
Constantin Graf
2592dd3b9e Fix local setup 2024-09-19 23:48:03 +02:00
Gregor Vostrak
05f240efc9 fix custom date picker update in reporting 2024-09-19 11:16:31 +02:00
Gregor Vostrak
d5b35ef420 improve billing banners on mobile 2024-09-17 22:32:43 +02:00
Gregor Vostrak
7e5374d5b1 add presets for date rage picker in reporting 2024-09-17 22:32:43 +02:00
Gregor Vostrak
36cdae523f fix bug where chart does not update project colors on data change 2024-09-17 22:32:43 +02:00
Gregor Vostrak
b2ad4b3785 add description grouping to reporting page (fixes ST-399), persist grouping selection in local storage 2024-09-17 22:32:43 +02:00
Constantin Graf
5e4270e3f5 Add time entry aggregation type “description” 2024-09-17 22:32:43 +02:00
Constantin Graf
d4e71e7c2c Lock import and increase timeout 2024-09-17 22:32:31 +02:00
Constantin Graf
5c6b32d5bb Deactivate auditing for time entries in importer 2024-09-16 21:50:01 +02:00
Constantin Graf
37400d239c Add command admin:user:verify 2024-09-13 17:59:10 +02:00
Constantin Graf
50902e7705 Renamed command admin:delete-organization to admin:organization:delete 2024-09-13 17:59:10 +02:00
Constantin Graf
498f29617e Add mapping for legacy timezones 2024-09-13 17:59:10 +02:00
Constantin Graf
61cc80dc6e Fixed export bug 2024-09-12 15:31:20 +02:00
Constantin Graf
0a0b7a03b4 Deactivate auditing for import and increase max_execution_time 2024-09-12 15:31:20 +02:00
Constantin Graf
cc10af0b97 Reduce overhead of health check endpoints 2024-09-12 15:31:20 +02:00
Constantin Graf
d3545b3c73 Allow time entries with less than one second duration 2024-09-12 15:31:20 +02:00
Gregor Vostrak
9e1413c15f unify and fix chart styles in dashboard and reporting view, fixes ST-356 2024-09-12 15:12:50 +02:00
Gregor Vostrak
ac85e778a4 fix error handling for organization export, fixes ST-426 2024-09-12 14:46:05 +02:00
Gregor Vostrak
9189910136 fix available roles filter, fixes ST-425 2024-09-12 14:41:23 +02:00
Gregor Vostrak
85315fc62f add client grouping and expandable project tasks to project task timetracker dropdown, fixes ST-253 2024-09-11 18:07:35 +02:00
Constantin Graf
91b56ae92f Fixed deprecation warning 2024-09-11 18:07:35 +02:00
Gregor Vostrak
845f0d19d8 add trial expiry day countdown to billing banner 2024-09-11 18:07:35 +02:00
Gregor Vostrak
d211e962f5 fix reporting multiselect dropdowns max height, fixes ST-414 2024-09-11 18:07:35 +02:00
Gregor Vostrak
f0705e1e4a fix sidebar navigation overflowing, add scrollbar only to nav items 2024-09-11 18:07:35 +02:00
Gregor Vostrak
b990387775 make No Project white in chart fixes ST-360 2024-09-11 18:07:35 +02:00
Gregor Vostrak
a4d6ba3cdb improve reporting chart, fix project table with long client name, fixes ST-414 2024-09-11 18:07:35 +02:00
Gregor Vostrak
3b41d90b07 fix layout bug in time view with small time entries, fixes ST-414 2024-09-11 18:07:35 +02:00
Gregor Vostrak
b391f47d1b fix scroll & jumping issues with task dropdown, fixes ST-395 2024-09-11 18:07:35 +02:00
Gregor Vostrak
19cc05140a add archiving for clients, fixes ST-279 2024-09-11 18:07:35 +02:00
Gregor Vostrak
5592d87cd5 fix e2e tests, filter requests to listen to correct time entry update request 2024-09-11 18:07:35 +02:00
Gregor Vostrak
b518187ecb Dashboard Data Refresh After creating a time entry, fixes ST-299 2024-09-11 18:07:35 +02:00
Gregor Vostrak
c09119af33 fix project member billable rate not shown correctly in modal, fixes ST-363 2024-09-11 18:07:35 +02:00
Constantin Graf
ceba49d054 Reverting phpstan update to prevent incorrect warnings 2024-09-11 18:07:35 +02:00
Constantin Graf
01dd13b947 Add getTrialUntil to BillingContract; Allow delete endpoints after blocking 2024-09-11 18:07:35 +02:00
Gregor Vostrak
83301d03ca respect billing permission in frontend, fix hiding of billing banners 2024-09-11 18:07:35 +02:00
Constantin Graf
4969fcba7e Add billing permission to owner 2024-09-11 18:07:35 +02:00
Gregor Vostrak
48b2bb436e show action blocked modal with instructions instead of small notification when server returns action blocked error 2024-09-11 18:07:35 +02:00
Gregor Vostrak
30ed47d3fb add trial banners and unblock member invite modal during trial 2024-09-11 18:07:35 +02:00
Gregor Vostrak
2bad9eaa3c chore: type OrganizationInvitation in DefaultImporter, new formatting rules 2024-09-11 18:07:35 +02:00
Constantin Graf
78b41ea0b7 Added reply to config 2024-09-11 18:07:35 +02:00
Constantin Graf
d8968399d6 Updated dependencies; Fixed codeformatting and phpstan 2024-09-11 18:07:35 +02:00
Constantin Graf
5b7df869ad Added trial and blocking to billing contract, fixed bug in running time tracker command 2024-09-11 18:07:35 +02:00
Constantin Graf
7c593f8f87 Enable auditing for unit testing 2024-09-11 17:58:29 +02:00
Gregor Vostrak
22b2933d85 open export downloads in the same window 2024-09-11 17:58:29 +02:00
Gregor Vostrak
6dd9d5bab0 add exporter in frontend, fixes ST-382 2024-09-11 17:58:29 +02:00
Constantin Graf
9a8945b0dc Add local setup for S3 2024-09-11 17:58:29 +02:00
Constantin Graf
fc614b796c Increaded timeout for ARM build 2024-09-10 19:40:57 +02:00
Constantin Graf
b031598f79 Added ARM build 2024-09-10 19:00:44 +02:00
270 changed files with 6135 additions and 2077 deletions

View File

@@ -27,7 +27,6 @@ DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=database
SESSION_LIFETIME=120
@@ -47,12 +46,6 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="${APP_NAME}"
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=us-east-1
S3_BUCKET=
S3_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
@@ -61,6 +54,17 @@ PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
# Storage
FILESYSTEM_DISK=s3
PUBLIC_FILESYSTEM_DISK=s3
S3_ACCESS_KEY_ID=sail
S3_SECRET_ACCESS_KEY=password
S3_REGION=us-east-1
S3_BUCKET=local
S3_URL=http://storage.solidtime.test/local
S3_ENDPOINT=http://storage.solidtime.test
S3_USE_PATH_STYLE_ENDPOINT=true
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"

View File

@@ -15,7 +15,7 @@ name: Build - Private
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 20
steps:
- name: "Check out code"
@@ -114,6 +114,9 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=sha,format=long
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
@@ -125,6 +128,7 @@ jobs:
DOCKER_FILES_BASE_PATH=docker/prod/
file: docker/prod/Dockerfile
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha

View File

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

View File

@@ -3,8 +3,6 @@ on:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-public.yml'
@@ -15,7 +13,12 @@ name: Build - Public
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
steps:
- name: "Check out code"
@@ -48,18 +51,28 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: solidtime/solidtime
images: |
solidtime/solidtime
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
@@ -70,7 +83,7 @@ jobs:
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: linux/amd64
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -62,7 +62,10 @@ class CreateNewUser implements CreatesNewUsers
if (app(TimezoneService::class)->isValid($input['timezone'])) {
$timezone = $input['timezone'];
} else {
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
$timezone = app(TimezoneService::class)->mapLegacyTimezone($input['timezone']);
if ($timezone === null) {
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
}
}
}
@@ -77,30 +80,31 @@ class CreateNewUser implements CreatesNewUsers
}
$currency = $ipLookupResponse->currency;
}
$user = DB::transaction(function () use ($input, $timezone, $startOfWeek, $currency) {
return tap(User::create([
$user = null;
$organization = null;
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'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,
]
);
$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();
$user->ownedTeams()->save($organization);
});
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
$user->ownedTeams()->save($organization);
});
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
@@ -12,7 +13,6 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\CreatesTeams;
use Laravel\Jetstream\Events\AddingTeam;
use Laravel\Jetstream\Jetstream;
class CreateOrganization implements CreatesTeams
@@ -33,9 +33,7 @@ class CreateOrganization implements CreatesTeams
'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createTeam');
AddingTeam::dispatch($user);
$organization = new Organization();
$organization = new Organization;
$organization->name = $input['name'];
$organization->personal_team = false;
$organization->owner()->associate($user);
@@ -47,10 +45,12 @@ class CreateOrganization implements CreatesTeams
]
);
$user->ownedTeams()->save($organization);
$user->switchTeam($organization);
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
$organization = $organization->refresh();
AfterCreateOrganization::dispatch($organization);
return $organization;
}
}

View File

@@ -19,6 +19,6 @@ class InviteOrganizationMember implements InvitesTeamMembers
*/
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
{
throw new MovedToApiException();
throw new MovedToApiException;
}
}

View File

@@ -19,6 +19,6 @@ class RemoveOrganizationMember implements RemovesTeamMembers
*/
public function remove(User $user, Organization $organization, User $teamMember): void
{
throw new MovedToApiException();
throw new MovedToApiException;
}
}

View File

@@ -20,6 +20,6 @@ class UpdateMemberRole
*/
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
{
throw new MovedToApiException();
throw new MovedToApiException;
}
}

View File

@@ -36,7 +36,7 @@ class UpdateOrganization implements UpdatesTeamNames
'currency' => [
'required',
'string',
new CurrencyRule(),
new CurrencyRule,
],
])->validateWithBag('updateTeamName');

View File

@@ -22,7 +22,7 @@ class ValidateOrganizationDeletion
public function validate(User $user, Organization $organization): void
{
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
throw new AuthorizationException();
throw new AuthorizationException;
}
}
}

View File

@@ -9,14 +9,14 @@ use App\Service\DeletionService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class DeleteOrganizationCommand extends Command
class OrganizationDeleteCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:delete-organization
protected $signature = 'admin:organization:delete
{ organization : The ID of the organization to delete }';
/**
@@ -24,7 +24,7 @@ class DeleteOrganizationCommand extends Command
*
* @var string
*/
protected $description = 'Delete a organization.';
protected $description = 'Delete a organization';
/**
* Execute the console command.

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Admin;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Console\Command;
class UserVerifyCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:user:verify
{ email : The email of the user to verify }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Verify the email address of an user';
/**
* Execute the console command.
*/
public function handle(): int
{
$email = $this->argument('email');
$this->info('Start verifying user with email "'.$email.'"');
/** @var User|null $user */
$user = User::where('email', $email)->first();
if ($user === null) {
$this->error('User with email "'.$email.'" not found.');
return self::FAILURE;
}
if ($user->hasVerifiedEmail()) {
$this->info('User with email "'.$email.'" already verified.');
return self::FAILURE;
}
$user->markEmailAsVerified();
event(new Verified($user));
$this->info('User with email "'.$email.'" has been verified.');
return self::SUCCESS;
}
}

View File

@@ -6,7 +6,9 @@ namespace App\Console\Commands\TimeEntry;
use App\Mail\TimeEntryStillRunningMail;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
@@ -47,8 +49,12 @@ class TimeEntrySendStillRunningMailsCommand extends Command
->with([
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
/** @var Collection<int, TimeEntry> $timeEntries */
foreach ($timeEntries as $timeEntry) {
$user = $timeEntry->user;

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum ProjectMemberRole: string
{
case Manager = 'manager';
case Normal = 'normal';
}

View File

@@ -15,6 +15,7 @@ enum TimeEntryAggregationType: string
case Task = 'task';
case Client = 'client';
case Billable = 'billable';
case Description = 'description';
public function toInterval(): ?TimeEntryAggregationTypeInterval
{

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* This event is fired after an organization has been created.
* This event does NOT fire when an organization is created as part of a registration.
*/
class AfterCreateOrganization
{
use Dispatchable;
use SerializesModels;
public Organization $organization;
public function __construct(Organization $organization)
{
$this->organization = $organization;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,20 +24,20 @@ class ApiExceptionTypeToSchema extends ExceptionToResponseExtension
public function toResponse(Type $type): Response
{
$validationResponseBodyType = (new OpenApiTypes\ObjectType())
$validationResponseBodyType = (new OpenApiTypes\ObjectType)
->addProperty(
'error',
(new OpenApiTypes\BooleanType())
(new OpenApiTypes\BooleanType)
->setDescription('Whether the response is an error.')
)
->addProperty(
'key',
(new OpenApiTypes\StringType())
(new OpenApiTypes\StringType)
->setDescription('Error key.')
)
->addProperty(
'message',
(new OpenApiTypes\StringType())
(new OpenApiTypes\StringType)
->setDescription('Error message.')
)
->setRequired(['error', 'key', 'message']);

View File

@@ -45,7 +45,7 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
}
$type = new OpenApiObjectType;
$type->addProperty('data', (new ArrayType())->setItems($collectingType));
$type->addProperty('data', (new ArrayType)->setItems($collectingType));
$type->addProperty(
'links',
(new OpenApiObjectType)

View File

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

View File

@@ -49,7 +49,7 @@ class TimeEntryResource extends Resource
->label('End')
->nullable()
->rules([
'after:start',
'after_or_equal:start',
]),
Select::make('user_id')
->relationship(name: 'user', titleAttribute: 'email')

View File

@@ -111,9 +111,18 @@ class UserResource extends Resource
->filters([
TernaryFilter::make('real_user')
->queries(
true: fn (Builder $query) => $query->where('is_placeholder', '=', false),
false: fn (Builder $query) => $query->where('is_placeholder', '=', true),
blank: fn (Builder $query) => $query,
true: function (Builder $query): Builder {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false);
},
false: function (Builder $query): Builder {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', true);
},
blank: function (Builder $query): Builder {
/** @var Builder<User> $query */
return $query;
},
)
->label('Real User?'),
TernaryFilter::make('email_verified')

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Models\TimeEntry;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
@@ -21,7 +22,8 @@ class ActiveUserOverview extends BaseWidget
$placeholderUserCount = User::query()->where('is_placeholder', '=', true)->count();
$activeInLastWeek = User::query()
->where('is_placeholder', '=', false)
->whereHas('timeEntries', function (Builder $query) {
->whereHas('timeEntries', function (Builder $query): void {
/** @var Builder<TimeEntry> $query */
$query->where('created_at', '>=', now()->subWeek())
->orWhere('updated_at', '>=', now()->subWeek());
})

View File

@@ -66,7 +66,7 @@ class ClientController extends Controller
{
$this->checkPermission($organization, 'clients:create');
$client = new Client();
$client = new Client;
$client->name = $request->input('name');
$client->organization()->associate($organization);
$client->save();

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Models\Organization;
use App\Service\BillingContract;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
@@ -12,8 +13,7 @@ class Controller extends \App\Http\Controllers\Controller
{
public function __construct(
protected PermissionStore $permissionStore,
) {
}
) {}
/**
* @throws AuthorizationException
@@ -21,7 +21,7 @@ class Controller extends \App\Http\Controllers\Controller
protected function checkPermission(Organization $organization, string $permission): void
{
if (! $this->permissionStore->has($organization, $permission)) {
throw new AuthorizationException();
throw new AuthorizationException;
}
}
@@ -37,11 +37,16 @@ class Controller extends \App\Http\Controllers\Controller
return;
}
}
throw new AuthorizationException();
throw new AuthorizationException;
}
protected function hasPermission(Organization $organization, string $permission): bool
{
return $this->permissionStore->has($organization, $permission);
}
protected function canAccessPremiumFeatures(Organization $organization): bool
{
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
}
}

View File

@@ -35,7 +35,7 @@ class ImportController extends Controller
foreach ($importers as $key => $importerClass) {
/** @var ImporterContract $importer */
$importer = new $importerClass();
$importer = new $importerClass;
$importersResponse[] = [
'key' => $key,
'name' => $importer->getName(),

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
@@ -80,16 +82,16 @@ class MemberController extends Controller
$newRole = $request->getRole();
$oldRole = Role::from($member->role);
if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner();
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed();
throw new ChangingRoleToPlaceholderIsNotAllowed;
}
if ($newRole === Role::Owner) {
if ($this->hasPermission($organization, 'members:change-ownership')) {
$memberService->changeOwnership($organization, $member);
} else {
throw new OnlyOwnerCanChangeOwnership();
throw new OnlyOwnerCanChangeOwnership;
}
} else {
$member->role = $request->getRole()->value;
@@ -118,15 +120,34 @@ class MemberController extends Controller
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization();
throw new CanNotRemoveOwnerFromOrganization;
}
$member->delete();
MemberRemoved::dispatch($member, $organization);
return response()
->json(null, 204);
}
/**
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
*/
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
$this->checkPermission($organization, 'members:make-placeholder', $member);
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
$memberService->makeMemberToPlaceholder($member);
MemberMadeToPlaceholder::dispatch($member, $organization);
return response()->json(null, 204);
}
/**
* Invite a placeholder member to become a real member of the organization
*
@@ -140,7 +161,7 @@ class MemberController extends Controller
$user = $member->user;
if (! $user->is_placeholder) {
throw new UserNotPlaceholderApiException();
throw new UserNotPlaceholderApiException;
}
$invitationService->inviteUser($organization, $user->email, Role::Employee);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\ProjectMemberRole;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Project\ProjectIndexRequest;
use App\Http\Requests\V1\Project\ProjectStoreRequest;
@@ -15,6 +16,8 @@ use App\Models\Project;
use App\Models\ProjectMember;
use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
@@ -50,6 +53,12 @@ class ProjectController extends Controller
if (! $canViewAllProjects) {
$projectsQuery->visibleByEmployee($user);
$projectsQuery->with([
'members' => function (HasMany $query): void {
/** @var Builder<ProjectMember> $query */
$query->whereBelongsTo($this->user(), 'user');
},
]);
}
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
@@ -60,6 +69,14 @@ class ProjectController extends Controller
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
foreach ($projects->items() as $project) {
if ($canViewAllProjects) {
$project->setAttribute('limited_visibility', false);
} else {
$project->setAttribute('limited_visibility', $project->members->firstWhere('user_id', $this->user()->id)?->role !== ProjectMemberRole::Manager);
}
}
return new ProjectCollection($projects);
}
@@ -73,6 +90,26 @@ class ProjectController extends Controller
public function show(Organization $organization, Project $project): JsonResource
{
$this->checkPermission($organization, 'projects:view', $project);
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
$project->load([
'members' => function (HasMany $query): void {
/** @var Builder<ProjectMember> $query */
$query->whereBelongsTo($this->user(), 'user');
},
]);
if (! $canViewAllProjects) {
if (! $project->is_public && $project->members->firstWhere('user_id', '=', $this->user()->id) === null) {
throw new AuthorizationException('No access to project');
}
}
if ($canViewAllProjects) {
$project->setAttribute('limited_visibility', false);
} else {
$project->setAttribute('limited_visibility', $project->members->firstWhere('user_id', $this->user()->id)?->role !== ProjectMemberRole::Manager);
}
$project->load('organization');
@@ -89,15 +126,20 @@ class ProjectController extends Controller
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
{
$this->checkPermission($organization, 'projects:create');
$project = new Project();
$project = new Project;
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$project->organization()->associate($organization);
$project->save();
$project->setAttribute('limited_visibility', false);
return new ProjectResource($project);
}
@@ -117,6 +159,9 @@ class ProjectController extends Controller
if ($request->has('is_archived')) {
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$oldBillableRate = $project->billable_rate;
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
@@ -126,6 +171,8 @@ class ProjectController extends Controller
$billableRateService->updateTimeEntriesBillableRateForProject($project);
}
$project->setAttribute('limited_visibility', false);
return new ProjectResource($project);
}
@@ -147,8 +194,8 @@ class ProjectController extends Controller
throw new EntityStillInUseApiException('project', 'time_entry');
}
DB::transaction(function () use (&$project) {
$project->members->each(function (ProjectMember $member) {
DB::transaction(function () use (&$project): void {
$project->members->each(function (ProjectMember $member): void {
$member->delete();
});

View File

@@ -65,13 +65,14 @@ class ProjectMemberController extends Controller
$member = Member::findOrFail((string) $request->input('member_id'));
if ($member->user->is_placeholder) {
throw new InactiveUserCanNotBeUsedApiException();
throw new InactiveUserCanNotBeUsedApiException;
}
if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($member, 'member')->exists()) {
throw new UserIsAlreadyMemberOfProjectApiException();
throw new UserIsAlreadyMemberOfProjectApiException;
}
$projectMember = new ProjectMember();
$projectMember = new ProjectMember;
$projectMember->role = $request->getRole();
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->member()->associate($member);
$projectMember->user()->associate($member->user);
@@ -95,11 +96,17 @@ class ProjectMemberController extends Controller
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();
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $projectMember->billable_rate;
$projectMember->billable_rate = $request->getBillableRate();
}
if ($request->getRole() !== null) {
$projectMember->role = $request->getRole();
}
$projectMember->save();
if ($oldBillableRate !== $request->getBillableRate()) {
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
}

View File

@@ -57,7 +57,7 @@ class TagController extends Controller
{
$this->checkPermission($organization, 'tags:create');
$tag = new Tag();
$tag = new Tag;
$tag->name = $request->input('name');
$tag->organization()->associate($organization);
$tag->save();

View File

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

View File

@@ -13,9 +13,12 @@ use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryResource;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
@@ -212,12 +215,21 @@ class TimeEntryController extends Controller
}
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {
throw new TimeEntryStillRunningApiException();
throw new TimeEntryStillRunningApiException;
}
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
$client = $project?->client;
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
$timeEntry = new TimeEntry();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$timeEntry = new TimeEntry;
$timeEntry->fill($request->validated());
$timeEntry->client()->associate($client);
$timeEntry->user_id = $member->user_id;
@@ -247,19 +259,41 @@ class TimeEntryController extends Controller
}
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
throw new TimeEntryCanNotBeRestartedApiException();
throw new TimeEntryCanNotBeRestartedApiException;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$project = null;
if ($request->has('project_id')) {
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
$client = $project?->client;
$timeEntry->client()->associate($client);
}
$task = null;
if ($request->has('task_id')) {
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
}
$timeEntry->fill($request->validated());
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
if ($oldProject !== null) {
RecalculateSpentTimeForProject::dispatch($oldProject);
}
if ($oldTask !== null) {
RecalculateSpentTimeForTask::dispatch($oldTask);
}
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
RecalculateSpentTimeForTask::dispatch($task);
}
return new TimeEntryResource($timeEntry);
}
@@ -279,24 +313,35 @@ class TimeEntryController extends Controller
$timeEntries = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->with([
'project',
'task',
])
->whereIn('id', $ids)
->get();
$changes = $request->validated('changes');
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
throw new AuthorizationException();
throw new AuthorizationException;
}
$project = null;
$client = null;
$overwriteClient = false;
if ($request->has('changes.project_id')) {
$client = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id'))->client : null;
$project = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id')) : null;
$client = $project?->client;
$overwriteClient = true;
}
$success = new Collection();
$error = new Collection();
$task = null;
if ($request->has('changes.task_id')) {
$task = $request->input('changes.task_id') !== null ? Task::findOrFail((string) $request->input('changes.task_id')) : null;
}
$success = new Collection;
$error = new Collection;
foreach ($ids as $id) {
/** @var TimeEntry|null $timeEntry */
@@ -313,12 +358,28 @@ class TimeEntryController extends Controller
continue;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$timeEntry->fill($changes);
if ($overwriteClient) {
$timeEntry->client()->associate($client);
}
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
if ($oldTask !== null) {
RecalculateSpentTimeForTask::dispatch($oldTask);
}
if ($oldProject !== null) {
RecalculateSpentTimeForProject::dispatch($oldProject);
}
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
RecalculateSpentTimeForTask::dispatch($task);
}
$success->push($id);
}
@@ -343,8 +404,18 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
}
$project = $timeEntry->project;
$task = $timeEntry->task;
$timeEntry->delete();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
return response()
->json(null, 204);
}

View File

@@ -28,7 +28,7 @@ class Controller extends BaseController
$user = Auth::user();
if ($user === null) {
Log::error('This function should only be called in authenticated context');
throw new AuthorizationException();
throw new AuthorizationException;
}
return $user;
@@ -44,7 +44,7 @@ class Controller extends BaseController
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
throw new AuthorizationException();
throw new AuthorizationException;
}
return $member;

View File

@@ -4,6 +4,4 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
abstract class Controller extends \App\Http\Controllers\Controller
{
}
abstract class Controller extends \App\Http\Controllers\Controller {}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http;
use App\Http\Middleware\CheckOrganizationBlocked;
use App\Http\Middleware\ForceJsonResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
@@ -50,6 +51,9 @@ class Kernel extends HttpKernel
\Illuminate\Routing\Middleware\SubstituteBindings::class,
ForceJsonResponse::class,
],
'health-check' => [
],
];
/**
@@ -71,5 +75,6 @@ class Kernel extends HttpKernel
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
'check-organization-blocked' => CheckOrganizationBlocked::class,
];
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Models\Organization;
use App\Service\BillingContract;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckOrganizationBlocked
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*
* @throws OrganizationHasNoSubscriptionButMultipleMembersException
*/
public function handle(Request $request, Closure $next): Response
{
$organization = $request->route('organization');
if (! ($organization instanceof Organization)) {
throw new \LogicException('The organization must be loaded before this middleware.');
}
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
if ($billing->isBlocked($organization)) {
throw new OrganizationHasNoSubscriptionButMultipleMembersException;
}
return $next($request);
}
}

View File

@@ -40,18 +40,19 @@ 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);
}
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
$currentOrganization = $request->user()?->currentTeam;
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
'billing' => $billing !== null ? [
'has_subscription' => $currentOrganization !== null ? $billing->hasSubscription($currentOrganization) : null,
'billing' => $billing !== null && $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
'is_blocked' => $billing->isBlocked($currentOrganization),
] : null,
'flash' => [
'message' => fn () => $request->session()->get('message'),

View File

@@ -41,7 +41,7 @@ class ProjectStoreRequest extends FormRequest
'required',
'string',
'max:255',
new ColorRule(),
new ColorRule,
],
'is_billable' => [
'required',
@@ -52,6 +52,7 @@ class ProjectStoreRequest extends FormRequest
'integer',
'min:0',
],
// ID of the client
'client_id' => [
'nullable',
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
@@ -59,6 +60,12 @@ class ProjectStoreRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
],
];
}
@@ -68,4 +75,11 @@ class ProjectStoreRequest extends FormRequest
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
}

View File

@@ -41,7 +41,7 @@ class ProjectUpdateRequest extends FormRequest
'required',
'string',
'max:255',
new ColorRule(),
new ColorRule,
],
'is_billable' => [
'required',
@@ -62,6 +62,12 @@ class ProjectUpdateRequest extends FormRequest
'integer',
'min:0',
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
],
];
}
@@ -78,4 +84,11 @@ class ProjectUpdateRequest extends FormRequest
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
}

View File

@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Enums\ProjectMemberRole;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -19,7 +21,7 @@ class ProjectMemberStoreRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
@@ -37,6 +39,11 @@ class ProjectMemberStoreRequest extends FormRequest
'integer',
'min:0',
],
'role' => [
'required',
'string',
Rule::enum(ProjectMemberRole::class),
],
];
}
@@ -46,4 +53,9 @@ class ProjectMemberStoreRequest extends FormRequest
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
public function getRole(): ProjectMemberRole
{
return ProjectMemberRole::from($this->validated('role'));
}
}

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Enums\ProjectMemberRole;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
@@ -16,7 +18,7 @@ class ProjectMemberUpdateRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
@@ -26,6 +28,10 @@ class ProjectMemberUpdateRequest extends FormRequest
'integer',
'min:0',
],
'role' => [
'string',
Rule::enum(ProjectMemberRole::class),
],
];
}
@@ -33,6 +39,11 @@ class ProjectMemberUpdateRequest extends FormRequest
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
return $input !== null && ((int) $input) !== 0 ? (int) $this->validated('billable_rate') : null;
}
public function getRole(): ?ProjectMemberRole
{
return $this->has('role') ? ProjectMemberRole::from($this->validated('role')) : null;
}
}

View File

@@ -43,6 +43,19 @@ class TaskStoreRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
],
];
}
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
}

View File

@@ -38,6 +38,12 @@ class TaskUpdateRequest extends FormRequest
'is_done' => [
'boolean',
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
],
];
}
@@ -47,4 +53,11 @@ class TaskUpdateRequest extends FormRequest
return $this->boolean('is_done');
}
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
}

View File

@@ -71,7 +71,7 @@ class TimeEntryStoreRequest extends FormRequest
'end' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
'after:start',
'after_or_equal:start',
],
// Whether time entry is billable
'billable' => [

View File

@@ -70,7 +70,7 @@ class TimeEntryUpdateRequest extends FormRequest
'end' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
'after:start',
'after_or_equal:start',
],
// Whether time entry is billable
'billable' => [

View File

@@ -4,6 +4,4 @@ declare(strict_types=1);
namespace App\Http\Resources;
interface PaginatedResourceCollection
{
}
interface PaginatedResourceCollection {}

View File

@@ -20,6 +20,8 @@ class ProjectResource extends BaseResource
*/
public function toArray(Request $request): array
{
$limitedVisibility = is_bool($this->resource->getAttributeValue('limited_visibility')) ? $this->resource->getAttributeValue('limited_visibility') : true;
return [
/** @var string $id ID of project */
'id' => $this->resource->id,
@@ -32,9 +34,15 @@ class ProjectResource extends BaseResource
/** @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,
'billable_rate' => $limitedVisibility ? null : $this->resource->billable_rate,
/** @var bool $is_billable Project time entries billable default */
'is_billable' => $this->resource->is_billable,
/** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $limitedVisibility ? null : $this->resource->estimated_time,
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $limitedVisibility ? null : $this->resource->spent_time,
/** @var bool $limited_visibility */
'limited_visibility' => $limitedVisibility,
];
}
}

View File

@@ -29,6 +29,8 @@ class ProjectMemberResource extends BaseResource
'member_id' => $this->resource->member_id,
/** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id,
/** @var string $role Role of the project member */
'role' => $this->resource->role->value,
];
}
}

View File

@@ -30,6 +30,10 @@ class TaskResource extends BaseResource
'is_done' => $this->resource->is_done,
/** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id,
/** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time,
/** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time,
/** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at When the tag was last updated */

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Listeners;
use App\Models\Member;
use App\Models\User;
use App\Service\UserService;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Jetstream\Events\TeamMemberAdded;
@@ -19,7 +20,8 @@ class RemovePlaceholder
/** @var UserService $userService */
$userService = app(UserService::class);
$placeholders = Member::query()
->whereHas('user', function (Builder $query) use ($event) {
->whereHas('user', function (Builder $query) use ($event): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', true)
->where('email', '=', $event->user->email);
})

View File

@@ -29,5 +29,6 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
*/
class Audit extends PackageAuditModel
{
/** @use HasFactory<AuditFactory> */
use HasFactory;
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -12,7 +13,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -29,8 +29,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/
class Client extends Model implements AuditableContract
{
use Auditable;
use CustomAuditable;
/** @use HasFactory<ClientFactory> */
use HasFactory;
use HasUuids;
/**
@@ -40,6 +43,7 @@ class Client extends Model implements AuditableContract
*/
protected $casts = [
'name' => 'string',
'archived_at' => 'datetime',
];
/**

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use OwenIt\Auditing\Auditable;
trait CustomAuditable
{
use Auditable;
/**
* @var array<string>|null
*/
protected ?array $auditEvents = null;
public function disableAuditing(): void
{
$this->auditEvents = [];
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use Database\Factories\FailedJobFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
@@ -16,6 +17,7 @@ use Illuminate\Support\Carbon;
*/
class FailedJob extends Model
{
/** @use HasFactory<FailedJobFactory> */
use HasFactory;
/**

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -11,7 +12,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Laravel\Jetstream\Membership as JetstreamMembership;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -29,8 +29,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/
class Member extends JetstreamMembership implements AuditableContract
{
use Auditable;
use CustomAuditable;
/** @use HasFactory<MemberFactory> */
use HasFactory;
use HasUuids;
/**

View File

@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\CustomAuditable;
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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
@@ -18,7 +20,6 @@ use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -41,8 +42,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/
class Organization extends JetstreamTeam implements AuditableContract
{
use Auditable;
use CustomAuditable;
/** @use HasFactory<OrganizationFactory> */
use HasFactory;
use HasUuids;
/**
@@ -110,7 +114,7 @@ class Organization extends JetstreamTeam implements AuditableContract
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel())
return $this->belongsToMany(User::class, Member::class)
->withPivot([
'id',
'role',
@@ -120,6 +124,24 @@ class Organization extends JetstreamTeam implements AuditableContract
->as('membership');
}
/**
* Get the owner of the team.
*
* @return BelongsTo<User, Organization>
*/
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* @return HasMany<Member>
*/
public function members(): HasMany
{
return $this->hasMany(Member::class);
}
/**
* @return BelongsToMany<User>
*/

View File

@@ -4,14 +4,13 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationInvitationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -27,8 +26,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/
class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract
{
use Auditable;
use CustomAuditable;
/** @use HasFactory<OrganizationInvitationFactory> */
use HasFactory;
use HasUuids;
/**
@@ -55,7 +57,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
*/
public function organization(): BelongsTo
{
return $this->belongsTo(Jetstream::teamModel(), 'organization_id');
return $this->belongsTo(Organization::class, 'organization_id');
}
/**
@@ -65,6 +67,6 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
*/
public function team(): BelongsTo
{
return $this->belongsTo(Jetstream::teamModel(), 'organization_id');
return $this->belongsTo(Organization::class, 'organization_id');
}
}

View File

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

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Models;
use App\Enums\ProjectMemberRole;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectMemberFactory;
use Illuminate\Database\Eloquent\Builder;
@@ -11,7 +13,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -22,6 +23,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $user_id User ID (legacy)
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property ProjectMemberRole $role
* @property-read Project $project
* @property-read Member $member
* @property-read User $user
@@ -31,8 +33,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/
class ProjectMember extends Model implements AuditableContract
{
use Auditable;
use CustomAuditable;
/** @use HasFactory<ProjectMemberFactory> */
use HasFactory;
use HasUuids;
/**
@@ -42,6 +47,7 @@ class ProjectMember extends Model implements AuditableContract
*/
protected $casts = [
'billable_rate' => 'int',
'role' => ProjectMemberRole::class,
];
/**

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\TagFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -25,8 +25,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/
class Tag extends Model implements AuditableContract
{
use Auditable;
use CustomAuditable;
/** @use HasFactory<TagFactory> */
use HasFactory;
use HasUuids;
/**

View File

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

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use App\Service\BillableRateService;
use Carbon\CarbonInterval;
@@ -14,7 +15,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -47,9 +47,12 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/
class TimeEntry extends Model implements AuditableContract
{
use Auditable;
use ComputedAttributes;
use CustomAuditable;
/** @use HasFactory<TimeEntryFactory> */
use HasFactory;
use HasUuids;
/**
@@ -78,6 +81,15 @@ class TimeEntry extends Model implements AuditableContract
'billable_rate',
];
/**
* Attributes to exclude from the Audit.
*
* @var array<string>
*/
protected array $auditExclude = [
'billable_rate',
];
public function getBillableRateComputed(): ?int
{
return app(BillableRateService::class)->getBillableRateForTimeEntry($this);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Enums\Weekday;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\UserFactory;
use Filament\Models\Contracts\FilamentUser;
@@ -25,7 +26,6 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Passport\HasApiTokens;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -57,9 +57,12 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail
{
use Auditable;
use CustomAuditable;
use HasApiTokens;
/** @use HasFactory<UserFactory> */
use HasFactory;
use HasProfilePhoto;
use HasTeams;
use HasUuids;

View File

@@ -81,10 +81,10 @@ class AppServiceProvider extends ServiceProvider
});
// Scramble
Scramble::extendOpenApi(function (OpenApi $openApi) {
Scramble::extendOpenApi(function (OpenApi $openApi): void {
$openApi->secure(
SecurityScheme::oauth2()
->flow('authorizationCode', function (OAuthFlow $flow) {
->flow('authorizationCode', function (OAuthFlow $flow): void {
$flow
->authorizationUrl('https://solidtime.test/oauth/authorize');
})
@@ -97,7 +97,7 @@ class AppServiceProvider extends ServiceProvider
}
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
return new PermissionStore();
return new PermissionStore;
});
// Extensions

View File

@@ -65,7 +65,7 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
$this->app->instance(LoginResponse::class, new CustomLoginResponse());
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse());
$this->app->instance(LoginResponse::class, new CustomLoginResponse);
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse);
}
}

View File

@@ -122,8 +122,10 @@ class JetstreamServiceProvider extends ServiceProvider
'members:view',
'members:invite-placeholder',
'members:change-ownership',
'members:make-placeholder',
'members:update',
'members:delete',
'billing',
])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Providers;
use App\Http\Controllers\Web\HealthCheckController;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
@@ -36,7 +37,13 @@ class RouteServiceProvider extends ServiceProvider
: Limit::perMinute(60)->by($request->ip());
});
$this->routes(function () {
$this->routes(function (): void {
Route::middleware('health-check')
->group(function (): void {
Route::get('health-check/up', [HealthCheckController::class, 'up']);
Route::get('health-check/debug', [HealthCheckController::class, 'debug']);
});
Route::middleware('api')
->prefix('api')
->name('api.')

View File

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

View File

@@ -5,11 +5,53 @@ declare(strict_types=1);
namespace App\Service;
use App\Models\Organization;
use Illuminate\Support\Carbon;
/**
* This class is a contract for the billing system
* The billing system is responsible for managing the subscriptions of organizations
* The concrete implementation of this contract for the cloud version of solidtime is implemented in an extension
*/
class BillingContract
{
/**
* Check if the organization has a Professional subscription
* A Professional subscription is a paid subscription that allows the organization to:
* - Have more than 1 non-placeholder member
* - Access features that are not available to free organizations
*/
public function hasSubscription(Organization $organization): bool
{
return false;
}
/**
* Check if the organization has a trial subscription
* A trial subscription gives the organization the same benefits as a Professional subscription, but for a limited time
*/
public function hasTrial(Organization $organization): bool
{
return false;
}
/**
* Get the date until which the organization's trial subscription is valid
* If the organization does not have a trial subscription, this method should return null
*/
public function getTrialUntil(Organization $organization): ?Carbon
{
return null;
}
/**
* Check if the organization is blocked
* A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial
* This can happen if:
* - The organization's trial has expired and during the trial the organization added non-placeholder members
* - The organization's subscription has expired and the organization has more than 1 non-placeholder member
*/
public function isBlocked(Organization $organization): bool
{
return false;
}
}

View File

@@ -30,7 +30,7 @@ class DashboardService
*/
private function lastDays(int $days, CarbonTimeZone $timeZone): Collection
{
$result = new Collection();
$result = new Collection;
$date = Carbon::now($timeZone)->subDays($days);
for ($i = 0; $i < $days; $i++) {
$date->addDay();
@@ -77,7 +77,7 @@ class DashboardService
*/
private function daysOfThisWeek(CarbonTimeZone $timeZone, Weekday $startOfWeek): Collection
{
$result = new Collection();
$result = new Collection;
$date = Carbon::now($timeZone);
$start = $date->startOfWeek($startOfWeek->carbonWeekDay());
for ($i = 0; $i < 7; $i++) {

View File

@@ -35,7 +35,7 @@ class DeletionService
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
{
if ($inTransaction) {
DB::transaction(function () use ($organization) {
DB::transaction(function () use ($organization): void {
$this->deleteOrganization($organization, false);
});
@@ -123,7 +123,7 @@ class DeletionService
public function deleteUser(User $user, bool $inTransaction = true): void
{
if ($inTransaction) {
DB::transaction(function () use ($user) {
DB::transaction(function () use ($user): void {
$this->deleteUser($user, false);
});
@@ -145,7 +145,7 @@ class DeletionService
foreach ($members as $member) {
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers();
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
}
}

View File

@@ -319,7 +319,7 @@ class ExportService
// Create ZIP file
$temporaryDirectoryZip = TemporaryDirectory::make();
$zip = new ZipArchive();
$zip = new ZipArchive;
if ($zip->open($temporaryDirectoryZip->path('export.zip'), ZipArchive::CREATE) !== true) {
throw new Exception('Cannot create ZIP file');
}
@@ -356,7 +356,7 @@ class ExportService
} catch (UnavailableStream|CannotInsertRecord|Exception|LeagueCsvException $exception) {
report($exception);
throw new ExportException();
throw new ExportException;
}
}
}

View File

@@ -98,13 +98,17 @@ class ImportDatabaseHelper
throw new ImportException('Invalid data: '.implode(', ', $validator->errors()->all()));
}
$model = new $this->model();
/** @var TModel $model */
$model = new $this->model;
foreach ($data as $key => $value) {
$model->{$key} = $value;
}
if ($this->beforeSave !== null) {
($this->beforeSave)($model);
}
if (method_exists($model, 'disableAuditing')) {
$model->disableAuditing();
}
$model->save();
if ($this->afterCreate !== null) {
@@ -240,7 +244,7 @@ class ImportDatabaseHelper
{
if ($this->mapIdentifierToKey === null) {
$select = $this->identifiers;
$select[] = (new $this->model())->getKeyName();
$select[] = (new $this->model)->getKeyName();
$builder = $this->getModelInstance();
if ($this->queryModifier !== null) {

View File

@@ -10,6 +10,7 @@ use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -27,9 +28,16 @@ class ImportService
Storage::disk(config('filesystems.default'))
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
DB::transaction(function () use (&$importer, &$data, &$timezone) {
$importer->importData($data, $timezone);
});
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
if ($lock->get()) {
DB::transaction(function () use (&$importer, &$data, &$timezone): void {
$importer->importData($data, $timezone);
});
$lock->release();
} else {
throw new ImportException('Import is already in progress');
}
return $importer->getReport();
}

View File

@@ -98,7 +98,8 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'organization_id' => $this->organization->id,
]);
}
$timeEntry = new TimeEntry();
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
$timeEntry->user_id = $userId;
$timeEntry->member_id = $memberId;
$timeEntry->task_id = $taskId;

View File

@@ -113,12 +113,12 @@ abstract class DefaultImporter implements ImporterContract
'nullable',
'integer',
],
], beforeSave: function (Project $project) {
], beforeSave: function (Project $project): void {
if ($project->billable_rate === 0) {
$project->billable_rate = null;
}
});
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder) {
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {
/** @var Builder<ProjectMember> $builder */
return $builder->whereBelongsToOrganization($this->organization);
}, validate: [
@@ -126,12 +126,13 @@ abstract class DefaultImporter implements ImporterContract
'nullable',
'integer',
],
], beforeSave: function (ProjectMember $projectMember) {
], beforeSave: function (ProjectMember $projectMember): void {
if ($projectMember->billable_rate === 0) {
$projectMember->billable_rate = null;
}
});
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) {
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->where('organization_id', $this->organization->id);
}, validate: [
'name' => [
@@ -139,7 +140,8 @@ abstract class DefaultImporter implements ImporterContract
'max:255',
],
]);
$this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) {
$this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->where('organization_id', $this->organization->id);
}, validate: [
'name' => [
@@ -147,7 +149,8 @@ abstract class DefaultImporter implements ImporterContract
'max:255',
],
]);
$this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) {
$this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->where('organization_id', $this->organization->id);
}, validate: [
'name' => [
@@ -156,6 +159,7 @@ abstract class DefaultImporter implements ImporterContract
],
]);
$this->organizationInvitationsImportHelper = new ImportDatabaseHelper(OrganizationInvitation::class, ['email', 'organization_id'], true, function (Builder $builder) {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->where('organization_id', $this->organization->id);
}, validate: [
'email' => [

View File

@@ -4,6 +4,4 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
class ImportException extends \Exception
{
}
class ImportException extends \Exception {}

View File

@@ -31,7 +31,7 @@ class SolidtimeImporter extends DefaultImporter
$temporaryDirectoryZip = null;
$temporaryDirectory = null;
try {
$zip = new ZipArchive();
$zip = new ZipArchive;
$temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
@@ -220,7 +220,8 @@ class SolidtimeImporter extends DefaultImporter
if ($timeEntryRow['task_id'] !== '') {
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
}
$timeEntry = new TimeEntry();
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
$timeEntry->user_id = $userId;
$timeEntry->member_id = $memberId;
$timeEntry->task_id = $taskId;

View File

@@ -23,7 +23,7 @@ class TogglDataImporter extends DefaultImporter
$temporaryDirectoryZip = null;
$temporaryDirectory = null;
try {
$zip = new ZipArchive();
$zip = new ZipArchive;
$temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);

View File

@@ -98,7 +98,8 @@ class TogglTimeEntriesImporter extends DefaultImporter
'organization_id' => $this->organization->id,
]);
}
$timeEntry = new TimeEntry();
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
$timeEntry->user_id = $userId;
$timeEntry->member_id = $memberId;
$timeEntry->task_id = $taskId;

View File

@@ -25,12 +25,12 @@ class InvitationService
->whereRelation('user', 'email', '=', $email)
->where('role', '!=', Role::Placeholder->value)
->exists()) {
throw new UserIsAlreadyMemberOfOrganizationApiException();
throw new UserIsAlreadyMemberOfOrganizationApiException;
}
InvitingTeamMember::dispatch($organization, $email, $role->value);
$invitation = new OrganizationInvitation();
$invitation = new OrganizationInvitation;
$invitation->email = $email;
$invitation->role = $role->value;
$invitation->organization()->associate($organization);

View File

@@ -270,6 +270,8 @@ class TimeEntryAggregationService
return 'client_id';
} elseif ($group === TimeEntryAggregationType::Billable) {
return 'billable';
} elseif ($group === TimeEntryAggregationType::Description) {
return 'description';
}
}
@@ -281,7 +283,7 @@ class TimeEntryAggregationService
if ($start->gt($end)) {
throw new \InvalidArgumentException('Start date must be before end date');
}
$slots = new Collection();
$slots = new Collection;
$current = $start->copy()->timezone($timezone);
if ($interval === TimeEntryAggregationTypeInterval::Day) {
$current->startOfDay();

View File

@@ -11,12 +11,276 @@ use Illuminate\Support\Facades\Log;
class TimezoneService
{
/**
* @source https://data.iana.org/time-zones/tzdb/backward
*/
private const array LEGACY_TIMEZONES_MAP = [
'Australia/ACT' => 'Australia/Sydney',
'Australia/LHI' => 'Australia/Lord_Howe',
'Australia/NSW' => 'Australia/Sydney',
'Australia/North' => 'Australia/Darwin',
'Australia/Queensland' => 'Australia/Brisbane',
'Australia/South' => 'Australia/Adelaide',
'Australia/Tasmania' => 'Australia/Hobart',
'Australia/Victoria' => 'Australia/Melbourne',
'Australia/West' => 'Australia/Perth',
'Australia/Yancowinna' => 'Australia/Broken_Hill',
'Brazil/Acre' => 'America/Rio_Branco',
'Brazil/DeNoronha' => 'America/Noronha',
'Brazil/East' => 'America/Sao_Paulo',
'Brazil/West' => 'America/Manaus',
'CET' => 'Europe/Brussels',
'CST6CDT' => 'America/Chicago',
'Canada/Atlantic' => 'America/Halifax',
'Canada/Central' => 'America/Winnipeg',
'Canada/Eastern' => 'America/Toronto',
'Canada/Mountain' => 'America/Edmonton',
'Canada/Newfoundland' => 'America/St_Johns',
'Canada/Pacific' => 'America/Vancouver',
'Canada/Saskatchewan' => 'America/Regina',
'Canada/Yukon' => 'America/Whitehorse',
'Chile/Continental' => 'America/Santiago',
'Chile/EasterIsland' => 'Pacific/Easter',
'Cuba' => 'America/Havana',
'EET' => 'Europe/Athens',
'EST' => 'America/Panama',
'EST5EDT' => 'America/New_York',
'Egypt' => 'Africa/Cairo',
'Eire' => 'Europe/Dublin',
'Etc/GMT+0' => 'Etc/GMT ',
'Etc/GMT-0' => 'Etc/GMT ',
'Etc/GMT0' => 'Etc/GMT ',
'Etc/Greenwich' => 'Etc/GMT ',
'Etc/UCT' => 'Etc/UTC ',
'Etc/Universal' => 'Etc/UTC ',
'Etc/Zulu' => 'Etc/UTC ',
'GB' => 'Europe/London',
'GB-Eire' => 'Europe/London',
'GMT+0' => 'Etc/GMT ',
'GMT-0' => 'Etc/GMT ',
'GMT0' => 'Etc/GMT ',
'Greenwich' => 'Etc/GMT ',
'Hongkong' => 'Asia/Hong_Kong',
'Iceland' => 'Africa/Abidjan',
'Iran' => 'Asia/Tehran',
'Israel' => 'Asia/Jerusalem',
'Jamaica' => 'America/Jamaica',
'Japan' => 'Asia/Tokyo',
'Kwajalein' => 'Pacific/Kwajalein',
'Libya' => 'Africa/Tripoli',
'MET' => 'Europe/Brussels',
'MST' => 'America/Phoenix',
'MST7MDT' => 'America/Denver',
'Mexico/BajaNorte' => 'America/Tijuana',
'Mexico/BajaSur' => 'America/Mazatlan',
'Mexico/General' => 'America/Mexico_City',
'NZ' => 'Pacific/Auckland',
'NZ-CHAT' => 'Pacific/Chatham',
'Navajo' => 'America/Denver',
'PRC' => 'Asia/Shanghai',
'Poland' => 'Europe/Warsaw',
'Portugal' => 'Europe/Lisbon',
'ROC' => 'Asia/Taipei',
'ROK' => 'Asia/Seoul',
'Singapore' => 'Asia/Singapore',
'Turkey' => 'Europe/Istanbul',
'UCT' => 'Etc/UTC ',
'US/Alaska' => 'America/Anchorage',
'US/Aleutian' => 'America/Adak',
'US/Arizona' => 'America/Phoenix',
'US/Central' => 'America/Chicago',
'US/East-Indiana' => 'America/Indiana/Indianapolis',
'US/Eastern' => 'America/New_York',
'US/Hawaii' => 'Pacific/Honolulu',
'US/Indiana-Starke' => 'America/Indiana/Knox',
'US/Michigan' => 'America/Detroit',
'US/Mountain' => 'America/Denver',
'US/Pacific' => 'America/Los_Angeles',
'US/Samoa' => 'Pacific/Pago_Pago',
'UTC' => 'Etc/UTC ',
'Universal' => 'Etc/UTC ',
'W-SU' => 'Europe/Moscow',
'Zulu' => 'Etc/UTC ',
'America/Buenos_Aires' => 'America/Argentina/Buenos_Aires',
'America/Catamarca' => 'America/Argentina/Catamarca',
'America/Cordoba' => 'America/Argentina/Cordoba',
'America/Indianapolis' => 'America/Indiana/Indianapolis',
'America/Jujuy' => 'America/Argentina/Jujuy',
'America/Knox_IN' => 'America/Indiana/Knox',
'America/Louisville' => 'America/Kentucky/Louisville',
'America/Mendoza' => 'America/Argentina/Mendoza',
'America/Virgin' => 'America/Puerto_Rico',
'Pacific/Samoa' => 'Pacific/Pago_Pago',
'Africa/Accra' => 'Africa/Abidjan',
'Africa/Addis_Ababa' => 'Africa/Nairobi',
'Africa/Asmara' => 'Africa/Nairobi',
'Africa/Bamako' => 'Africa/Abidjan',
'Africa/Bangui' => 'Africa/Lagos',
'Africa/Banjul' => 'Africa/Abidjan',
'Africa/Blantyre' => 'Africa/Maputo',
'Africa/Brazzaville' => 'Africa/Lagos',
'Africa/Bujumbura' => 'Africa/Maputo',
'Africa/Conakry' => 'Africa/Abidjan',
'Africa/Dakar' => 'Africa/Abidjan',
'Africa/Dar_es_Salaam' => 'Africa/Nairobi',
'Africa/Djibouti' => 'Africa/Nairobi',
'Africa/Douala' => 'Africa/Lagos',
'Africa/Freetown' => 'Africa/Abidjan',
'Africa/Gaborone' => 'Africa/Maputo',
'Africa/Harare' => 'Africa/Maputo',
'Africa/Kampala' => 'Africa/Nairobi',
'Africa/Kigali' => 'Africa/Maputo',
'Africa/Kinshasa' => 'Africa/Lagos',
'Africa/Libreville' => 'Africa/Lagos',
'Africa/Lome' => 'Africa/Abidjan',
'Africa/Luanda' => 'Africa/Lagos',
'Africa/Lubumbashi' => 'Africa/Maputo',
'Africa/Lusaka' => 'Africa/Maputo',
'Africa/Malabo' => 'Africa/Lagos',
'Africa/Maseru' => 'Africa/Johannesburg',
'Africa/Mbabane' => 'Africa/Johannesburg',
'Africa/Mogadishu' => 'Africa/Nairobi',
'Africa/Niamey' => 'Africa/Lagos',
'Africa/Nouakchott' => 'Africa/Abidjan',
'Africa/Ouagadougou' => 'Africa/Abidjan',
'Africa/Porto-Novo' => 'Africa/Lagos',
'America/Anguilla' => 'America/Puerto_Rico',
'America/Antigua' => 'America/Puerto_Rico',
'America/Aruba' => 'America/Puerto_Rico',
'America/Atikokan' => 'America/Panama',
'America/Blanc-Sablon' => 'America/Puerto_Rico',
'America/Cayman' => 'America/Panama',
'America/Creston' => 'America/Phoenix',
'America/Curacao' => 'America/Puerto_Rico',
'America/Dominica' => 'America/Puerto_Rico',
'America/Grenada' => 'America/Puerto_Rico',
'America/Guadeloupe' => 'America/Puerto_Rico',
'America/Kralendijk' => 'America/Puerto_Rico',
'America/Lower_Princes' => 'America/Puerto_Rico',
'America/Marigot' => 'America/Puerto_Rico',
'America/Montserrat' => 'America/Puerto_Rico',
'America/Nassau' => 'America/Toronto',
'America/Port_of_Spain' => 'America/Puerto_Rico',
'America/St_Barthelemy' => 'America/Puerto_Rico',
'America/St_Kitts' => 'America/Puerto_Rico',
'America/St_Lucia' => 'America/Puerto_Rico',
'America/St_Thomas' => 'America/Puerto_Rico',
'America/St_Vincent' => 'America/Puerto_Rico',
'America/Tortola' => 'America/Puerto_Rico',
'Antarctica/DumontDUrville' => 'Pacific/Port_Moresby',
'Antarctica/McMurdo' => 'Pacific/Auckland',
'Antarctica/Syowa' => 'Asia/Riyadh',
'Arctic/Longyearbyen' => 'Europe/Berlin',
'Asia/Aden' => 'Asia/Riyadh',
'Asia/Bahrain' => 'Asia/Qatar',
'Asia/Brunei' => 'Asia/Kuching',
'Asia/Kuala_Lumpur' => 'Asia/Singapore',
'Asia/Kuwait' => 'Asia/Riyadh',
'Asia/Muscat' => 'Asia/Dubai',
'Asia/Phnom_Penh' => 'Asia/Bangkok',
'Asia/Vientiane' => 'Asia/Bangkok',
'Atlantic/Reykjavik' => 'Africa/Abidjan',
'Atlantic/St_Helena' => 'Africa/Abidjan',
'Europe/Amsterdam' => 'Europe/Brussels',
'Europe/Bratislava' => 'Europe/Prague',
'Europe/Busingen' => 'Europe/Zurich',
'Europe/Copenhagen' => 'Europe/Berlin',
'Europe/Guernsey' => 'Europe/London',
'Europe/Isle_of_Man' => 'Europe/London',
'Europe/Jersey' => 'Europe/London',
'Europe/Ljubljana' => 'Europe/Belgrade',
'Europe/Luxembourg' => 'Europe/Brussels',
'Europe/Mariehamn' => 'Europe/Helsinki',
'Europe/Monaco' => 'Europe/Paris',
'Europe/Oslo' => 'Europe/Berlin',
'Europe/Podgorica' => 'Europe/Belgrade',
'Europe/San_Marino' => 'Europe/Rome',
'Europe/Sarajevo' => 'Europe/Belgrade',
'Europe/Skopje' => 'Europe/Belgrade',
'Europe/Stockholm' => 'Europe/Berlin',
'Europe/Vaduz' => 'Europe/Zurich',
'Europe/Vatican' => 'Europe/Rome',
'Europe/Zagreb' => 'Europe/Belgrade',
'Indian/Antananarivo' => 'Africa/Nairobi',
'Indian/Christmas' => 'Asia/Bangkok',
'Indian/Cocos' => 'Asia/Yangon',
'Indian/Comoro' => 'Africa/Nairobi',
'Indian/Kerguelen' => 'Indian/Maldives',
'Indian/Mahe' => 'Asia/Dubai',
'Indian/Mayotte' => 'Africa/Nairobi',
'Indian/Reunion' => 'Asia/Dubai',
'Pacific/Chuuk' => 'Pacific/Port_Moresby',
'Pacific/Funafuti' => 'Pacific/Tarawa',
'Pacific/Majuro' => 'Pacific/Tarawa',
'Pacific/Midway' => 'Pacific/Pago_Pago',
'Pacific/Pohnpei' => 'Pacific/Guadalcanal',
'Pacific/Saipan' => 'Pacific/Guam',
'Pacific/Wake' => 'Pacific/Tarawa',
'Pacific/Wallis' => 'Pacific/Tarawa',
'Africa/Timbuktu' => 'Africa/Abidjan',
'America/Argentina/ComodRivadavia' => 'America/Argentina/Catamarca',
'America/Atka' => 'America/Adak',
'America/Coral_Harbour' => 'America/Panama',
'America/Ensenada' => 'America/Tijuana',
'America/Fort_Wayne' => 'America/Indiana/Indianapolis',
'America/Montreal' => 'America/Toronto',
'America/Nipigon' => 'America/Toronto',
'America/Pangnirtung' => 'America/Iqaluit',
'America/Porto_Acre' => 'America/Rio_Branco',
'America/Rainy_River' => 'America/Winnipeg',
'America/Rosario' => 'America/Argentina/Cordoba',
'America/Santa_Isabel' => 'America/Tijuana',
'America/Shiprock' => 'America/Denver',
'America/Thunder_Bay' => 'America/Toronto',
'America/Yellowknife' => 'America/Edmonton',
'Antarctica/South_Pole' => 'Pacific/Auckland',
'Asia/Choibalsan' => 'Asia/Ulaanbaatar',
'Asia/Chongqing' => 'Asia/Shanghai',
'Asia/Harbin' => 'Asia/Shanghai',
'Asia/Kashgar' => 'Asia/Urumqi',
'Asia/Tel_Aviv' => 'Asia/Jerusalem',
'Atlantic/Jan_Mayen' => 'Europe/Berlin',
'Australia/Canberra' => 'Australia/Sydney',
'Australia/Currie' => 'Australia/Hobart',
'Europe/Belfast' => 'Europe/London',
'Europe/Tiraspol' => 'Europe/Chisinau',
'Europe/Uzhgorod' => 'Europe/Kyiv',
'Europe/Zaporozhye' => 'Europe/Kyiv',
'Pacific/Enderbury' => 'Pacific/Kanton',
'Pacific/Johnston' => 'Pacific/Honolulu',
'Pacific/Yap' => 'Pacific/Port_Moresby',
'WET' => 'Europe/Lisbon',
'Africa/Asmera' => 'Africa/Nairobi',
'America/Godthab' => 'America/Nuuk',
'Asia/Ashkhabad' => 'Asia/Ashgabat',
'Asia/Calcutta' => 'Asia/Kolkata',
'Asia/Chungking' => 'Asia/Shanghai',
'Asia/Dacca' => 'Asia/Dhaka',
'Asia/Istanbul' => 'Europe/Istanbul',
'Asia/Katmandu' => 'Asia/Kathmandu',
'Asia/Macao' => 'Asia/Macau',
'Asia/Rangoon' => 'Asia/Yangon',
'Asia/Saigon' => 'Asia/Ho_Chi_Minh',
'Asia/Thimbu' => 'Asia/Thimphu',
'Asia/Ujung_Pandang' => 'Asia/Makassar',
'Asia/Ulan_Bator' => 'Asia/Ulaanbaatar',
'Atlantic/Faeroe' => 'Atlantic/Faroe',
'Europe/Kiev' => 'Europe/Kyiv',
'Europe/Nicosia' => 'Asia/Nicosia',
'HST' => 'Pacific/Honolulu',
'PST8PDT' => 'America/Los_Angeles',
'Pacific/Ponape' => 'Pacific/Guadalcanal',
'Pacific/Truk' => 'Pacific/Port_Moresby',
];
/**
* @return array<string>
*/
public function getTimezones(): array
public function getTimezones(bool $inclLegacy = false): array
{
return CarbonTimeZone::listIdentifiers();
return $inclLegacy ?
CarbonTimeZone::listIdentifiers(CarbonTimeZone::ALL_WITH_BC) :
CarbonTimeZone::listIdentifiers();
}
public function getTimezoneFromUser(User $user): CarbonTimeZone
@@ -52,6 +316,11 @@ class TimezoneService
return in_array($timezone, $this->getTimezones(), true);
}
public function mapLegacyTimezone(string $timezone): ?string
{
return self::LEGACY_TIMEZONES_MAP[$timezone] ?? null;
}
public function getShiftFromUtc(CarbonTimeZone $timeZone): int
{
return $timeZone->getOffset(Carbon::now());

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Models\Member;
use App\Models\Organization;
use App\Models\ProjectMember;
@@ -59,7 +60,7 @@ class UserService
}
// Create a new organization
$organization = new Organization();
$organization = new Organization;
$organization->name = $user->name."'s Organization";
$organization->personal_team = true;
$organization->user_id = $user->id;
@@ -71,6 +72,8 @@ class UserService
// Set the organization as the user's current organization
$user->currentOrganization()->associate($organization);
$user->save();
AfterCreateOrganization::dispatch($organization);
}
public function makeSureUserHasCurrentOrganization(User $user): void

View File

@@ -16,7 +16,7 @@
"inertiajs/inertia-laravel": "^1.0",
"korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0",
"laravel/framework": "^11.0",
"laravel/framework": "^11.16.0",
"laravel/jetstream": "^5.0",
"laravel/octane": "^2.3",
"laravel/passport": "^12.0",
@@ -29,7 +29,7 @@
"spatie/temporary-directory": "^2.2",
"stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0",
"tpetry/laravel-postgresql-enhanced": "^0.39.0",
"tpetry/laravel-postgresql-enhanced": "^1.0.0",
"wikimedia/composer-merge-plugin": "^2.1.0"
},
"require-dev": {
@@ -37,6 +37,7 @@
"brianium/paratest": "^7.3",
"fakerphp/faker": "^1.9.1",
"fumeapp/modeltyper": "^2.2",
"phpstan/phpstan": "1.12.0",
"larastan/larastan": "^2.0",
"laravel/pint": "^1.0",
"laravel/sail": "^1.18",

1837
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,7 @@ return [
'region' => env('S3_REGION'),
'bucket' => env('S3_BUCKET'),
'url' => env('S3_URL'),
'temporary_url' => env('S3_URL'),
'endpoint' => env('S3_ENDPOINT'),
'use_path_style_endpoint' => env('S3_USE_PATH_STYLE_ENDPOINT', false),
'throw' => true,

View File

@@ -114,6 +114,11 @@ return [
'name' => env('MAIL_FROM_NAME', 'Example'),
],
'reply_to' => [
'address' => env('MAIL_REPLY_TO_ADDRESS'),
'name' => env('MAIL_REPLY_TO_NAME'),
],
/*
|--------------------------------------------------------------------------
| Markdown Mail Settings

View File

@@ -221,7 +221,7 @@ return [
|
*/
'max_execution_time' => 30,
'max_execution_time' => 120,
/**
* Custom swoole config

View File

@@ -29,14 +29,14 @@ class AuditFactory extends Factory
return User::factory()->create()->id;
},
$morphPrefix.'_type' => function () {
return (new User())->getMorphClass();
return (new User)->getMorphClass();
},
'event' => 'updated',
'auditable_id' => function () {
return User::factory()->create()->getKey();
},
'auditable_type' => function () {
return (new User())->getMorphClass();
return (new User)->getMorphClass();
},
'old_values' => [],
'new_values' => [],

View File

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

View File

@@ -49,4 +49,11 @@ class OrganizationFactory extends Factory
'user_id' => $owner === null ? User::factory() : $owner->getKey(),
]);
}
public function withFakeId(): self
{
return $this->state(fn (array $attributes) => [
'id' => $this->faker->uuid(),
]);
}
}

View File

@@ -33,9 +33,19 @@ class ProjectFactory extends Factory
'archived_at' => null,
'client_id' => null,
'organization_id' => Organization::factory(),
'estimated_time' => null,
];
}
public function withEstimatedTime(): self
{
return $this->state(function (array $attributes): array {
return [
'estimated_time' => $this->faker->randomNumber(3),
];
});
}
public function billable(): self
{
return $this->state(function (array $attributes): array {

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