mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
55 Commits
v0.2.0
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b84ba67cd | ||
|
|
8b50f33cc9 | ||
|
|
014bffe86d | ||
|
|
2dbde63043 | ||
|
|
876a41cb2a | ||
|
|
1036502e49 | ||
|
|
5bf4dc79c2 | ||
|
|
2592dd3b9e | ||
|
|
05f240efc9 | ||
|
|
d5b35ef420 | ||
|
|
7e5374d5b1 | ||
|
|
36cdae523f | ||
|
|
b2ad4b3785 | ||
|
|
5e4270e3f5 | ||
|
|
d4e71e7c2c | ||
|
|
5c6b32d5bb | ||
|
|
37400d239c | ||
|
|
50902e7705 | ||
|
|
498f29617e | ||
|
|
61cc80dc6e | ||
|
|
0a0b7a03b4 | ||
|
|
cc10af0b97 | ||
|
|
d3545b3c73 | ||
|
|
9e1413c15f | ||
|
|
ac85e778a4 | ||
|
|
9189910136 | ||
|
|
85315fc62f | ||
|
|
91b56ae92f | ||
|
|
845f0d19d8 | ||
|
|
d211e962f5 | ||
|
|
f0705e1e4a | ||
|
|
b990387775 | ||
|
|
a4d6ba3cdb | ||
|
|
3b41d90b07 | ||
|
|
b391f47d1b | ||
|
|
19cc05140a | ||
|
|
5592d87cd5 | ||
|
|
b518187ecb | ||
|
|
c09119af33 | ||
|
|
ceba49d054 | ||
|
|
01dd13b947 | ||
|
|
83301d03ca | ||
|
|
4969fcba7e | ||
|
|
48b2bb436e | ||
|
|
30ed47d3fb | ||
|
|
2bad9eaa3c | ||
|
|
78b41ea0b7 | ||
|
|
d8968399d6 | ||
|
|
5b7df869ad | ||
|
|
7c593f8f87 | ||
|
|
22b2933d85 | ||
|
|
6dd9d5bab0 | ||
|
|
9a8945b0dc | ||
|
|
fc614b796c | ||
|
|
b031598f79 |
18
.env.example
18
.env.example
@@ -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}"
|
||||
|
||||
6
.github/workflows/build-private.yml
vendored
6
.github/workflows/build-private.yml
vendored
@@ -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
|
||||
|
||||
90
.github/workflows/build-public-release.yml
vendored
Normal file
90
.github/workflows/build-public-release.yml
vendored
Normal 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
|
||||
25
.github/workflows/build-public.yml
vendored
25
.github/workflows/build-public.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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) {
|
||||
$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'];
|
||||
|
||||
@@ -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.')),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ class RemoveOrganizationMember implements RemovesTeamMembers
|
||||
*/
|
||||
public function remove(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
throw new MovedToApiException();
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@ class UpdateMemberRole
|
||||
*/
|
||||
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
||||
{
|
||||
throw new MovedToApiException();
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class UpdateOrganization implements UpdatesTeamNames
|
||||
'currency' => [
|
||||
'required',
|
||||
'string',
|
||||
new CurrencyRule(),
|
||||
new CurrencyRule,
|
||||
],
|
||||
])->validateWithBag('updateTeamName');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
59
app/Console/Commands/Admin/UserVerifyCommand.php
Normal file
59
app/Console/Commands/Admin/UserVerifyCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,6 +49,10 @@ 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) {
|
||||
/** @var Collection<int, TimeEntry> $timeEntries */
|
||||
|
||||
@@ -15,6 +15,7 @@ enum TimeEntryAggregationType: string
|
||||
case Task = 'task';
|
||||
case Client = 'client';
|
||||
case Billable = 'billable';
|
||||
case Description = 'description';
|
||||
|
||||
public function toInterval(): ?TimeEntryAggregationTypeInterval
|
||||
{
|
||||
|
||||
26
app/Events/AfterCreateOrganization.php
Normal file
26
app/Events/AfterCreateOrganization.php
Normal 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;
|
||||
}
|
||||
}
|
||||
24
app/Events/MemberMadeToPlaceholder.php
Normal file
24
app/Events/MemberMadeToPlaceholder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
24
app/Events/MemberRemoved.php
Normal file
24
app/Events/MemberRemoved.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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());
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -12,8 +12,7 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionStore $permissionStore,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
@@ -21,7 +20,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,7 +36,7 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
protected function hasPermission(Organization $organization, string $permission): bool
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -89,7 +89,7 @@ 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');
|
||||
|
||||
@@ -65,13 +65,13 @@ 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->billable_rate = $request->getBillableRate();
|
||||
$projectMember->member()->associate($member);
|
||||
$projectMember->user()->associate($member->user);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -76,7 +76,7 @@ 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');
|
||||
$task->organization()->associate($organization);
|
||||
|
||||
@@ -212,12 +212,12 @@ 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;
|
||||
|
||||
$timeEntry = new TimeEntry();
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->client()->associate($client);
|
||||
$timeEntry->user_id = $member->user_id;
|
||||
@@ -247,7 +247,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
|
||||
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
||||
throw new TimeEntryCanNotBeRestartedApiException();
|
||||
throw new TimeEntryCanNotBeRestartedApiException;
|
||||
}
|
||||
|
||||
if ($request->has('project_id')) {
|
||||
@@ -285,7 +285,7 @@ class TimeEntryController extends Controller
|
||||
$changes = $request->validated('changes');
|
||||
|
||||
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$client = null;
|
||||
@@ -295,8 +295,8 @@ class TimeEntryController extends Controller
|
||||
$overwriteClient = true;
|
||||
}
|
||||
|
||||
$success = new Collection();
|
||||
$error = new Collection();
|
||||
$success = new Collection;
|
||||
$error = new Collection;
|
||||
|
||||
foreach ($ids as $id) {
|
||||
/** @var TimeEntry|null $timeEntry */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
40
app/Http/Middleware/CheckOrganizationBlocked.php
Normal file
40
app/Http/Middleware/CheckOrganizationBlocked.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -41,7 +41,7 @@ class ProjectStoreRequest extends FormRequest
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
new ColorRule(),
|
||||
new ColorRule,
|
||||
],
|
||||
'is_billable' => [
|
||||
'required',
|
||||
|
||||
@@ -41,7 +41,7 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
new ColorRule(),
|
||||
new ColorRule,
|
||||
],
|
||||
'is_billable' => [
|
||||
'required',
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -4,6 +4,4 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
interface PaginatedResourceCollection
|
||||
{
|
||||
}
|
||||
interface PaginatedResourceCollection {}
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -29,5 +29,6 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
||||
*/
|
||||
class Audit extends PackageAuditModel
|
||||
{
|
||||
/** @use HasFactory<AuditFactory> */
|
||||
use HasFactory;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
22
app/Models/Concerns/CustomAuditable.php
Normal file
22
app/Models/Concerns/CustomAuditable.php
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
/**
|
||||
@@ -40,8 +40,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
*/
|
||||
class Project extends Model implements AuditableContract
|
||||
{
|
||||
use Auditable;
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<ProjectFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
@@ -52,6 +55,7 @@ class Project extends Model implements AuditableContract
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'color' => 'string',
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ProjectMemberFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -11,7 +12,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;
|
||||
|
||||
/**
|
||||
@@ -31,8 +31,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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
/**
|
||||
@@ -34,8 +34,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
*/
|
||||
class Task extends Model implements AuditableContract
|
||||
{
|
||||
use Auditable;
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<TaskFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -97,7 +97,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||
return new PermissionStore();
|
||||
return new PermissionStore;
|
||||
});
|
||||
|
||||
// Extensions
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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;
|
||||
@@ -37,6 +38,12 @@ class RouteServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
$this->routes(function () {
|
||||
Route::middleware('health-check')
|
||||
->group(function () {
|
||||
Route::get('health-check/up', [HealthCheckController::class, 'up']);
|
||||
Route::get('health-check/debug', [HealthCheckController::class, 'debug']);
|
||||
});
|
||||
|
||||
Route::middleware('api')
|
||||
->prefix('api')
|
||||
->name('api.')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
$importer->importData($data, $timezone);
|
||||
});
|
||||
$lock->release();
|
||||
} else {
|
||||
throw new ImportException('Import is already in progress');
|
||||
}
|
||||
|
||||
return $importer->getReport();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -118,7 +118,7 @@ abstract class DefaultImporter implements ImporterContract
|
||||
$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: [
|
||||
@@ -131,7 +131,8 @@ abstract class DefaultImporter implements ImporterContract
|
||||
$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' => [
|
||||
|
||||
@@ -4,6 +4,4 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
class ImportException extends \Exception
|
||||
{
|
||||
}
|
||||
class ImportException extends \Exception {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
1837
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -221,7 +221,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'max_execution_time' => 30,
|
||||
'max_execution_time' => 120,
|
||||
|
||||
/**
|
||||
* Custom swoole config
|
||||
|
||||
@@ -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' => [],
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class TagFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->name,
|
||||
'name' => $this->faker->name(),
|
||||
'organization_id' => Organization::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -154,15 +154,15 @@ class DatabaseSeeder extends Seeder
|
||||
|
||||
private function deleteAll(): void
|
||||
{
|
||||
DB::table((new Audit())->getTable())->delete();
|
||||
DB::table((new TimeEntry())->getTable())->delete();
|
||||
DB::table((new Task())->getTable())->delete();
|
||||
DB::table((new Tag())->getTable())->delete();
|
||||
DB::table((new ProjectMember())->getTable())->delete();
|
||||
DB::table((new Project())->getTable())->delete();
|
||||
DB::table((new Client())->getTable())->delete();
|
||||
DB::table((new User())->getTable())->delete();
|
||||
DB::table((new OrganizationInvitation())->getTable())->delete();
|
||||
DB::table((new Organization())->getTable())->delete();
|
||||
DB::table((new Audit)->getTable())->delete();
|
||||
DB::table((new TimeEntry)->getTable())->delete();
|
||||
DB::table((new Task)->getTable())->delete();
|
||||
DB::table((new Tag)->getTable())->delete();
|
||||
DB::table((new ProjectMember)->getTable())->delete();
|
||||
DB::table((new Project)->getTable())->delete();
|
||||
DB::table((new Client)->getTable())->delete();
|
||||
DB::table((new User)->getTable())->delete();
|
||||
DB::table((new OrganizationInvitation)->getTable())->delete();
|
||||
DB::table((new Organization)->getTable())->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ services:
|
||||
- "traefik.http.routers.solidtime-dev-vite.service=solidtime-dev-vite"
|
||||
- "traefik.http.routers.solidtime-dev-vite.entrypoints=web"
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}"
|
||||
environment:
|
||||
XDG_CONFIG_HOME: /var/www/html/config
|
||||
XDG_DATA_HOME: /var/www/html/data
|
||||
@@ -128,6 +129,66 @@ services:
|
||||
- reverse-proxy
|
||||
volumes:
|
||||
- '.:/src'
|
||||
minio:
|
||||
image: 'minio/minio:latest'
|
||||
environment:
|
||||
MINIO_BROWSER_REDIRECT_URL: 'https://storage-management.${NGINX_HOST_NAME}'
|
||||
MINIO_ROOT_USER: 'sail'
|
||||
MINIO_ROOT_PASSWORD: 'password'
|
||||
volumes:
|
||||
- 'sail-minio:/data/minio'
|
||||
networks:
|
||||
- reverse-proxy
|
||||
- sail
|
||||
command: minio server /data/minio --console-address ":8900"
|
||||
healthcheck:
|
||||
test: [ "CMD", "mc", "ready", "local" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=${NETWORK_NAME}"
|
||||
# Storage Frontend
|
||||
- "traefik.http.services.solidtime-dev-storage-frontend.loadbalancer.server.port=9000"
|
||||
# http
|
||||
- "traefik.http.routers.solidtime-dev-storage-frontend.rule=Host(`storage.${NGINX_HOST_NAME}`)"
|
||||
- "traefik.http.routers.solidtime-dev-storage-frontend.service=solidtime-dev-storage-frontend"
|
||||
- "traefik.http.routers.solidtime-dev-storage-frontend.entrypoints=web"
|
||||
# https
|
||||
- "traefik.http.routers.solidtime-dev-storage-frontend-https.rule=Host(`storage.${NGINX_HOST_NAME}`)"
|
||||
- "traefik.http.routers.solidtime-dev-storage-frontend-https.service=solidtime-dev-storage-frontend"
|
||||
- "traefik.http.routers.solidtime-dev-storage-frontend-https.entrypoints=websecure"
|
||||
- "traefik.http.routers.solidtime-dev-storage-frontend-https.tls=true"
|
||||
# Storage Management
|
||||
- "traefik.http.services.solidtime-dev-storage-management.loadbalancer.server.port=8900"
|
||||
# http
|
||||
- "traefik.http.routers.solidtime-dev-storage-management.rule=Host(`storage-management.${NGINX_HOST_NAME}`)"
|
||||
- "traefik.http.routers.solidtime-dev-storage-management.service=solidtime-dev-storage-management"
|
||||
- "traefik.http.routers.solidtime-dev-storage-management.entrypoints=web"
|
||||
# https
|
||||
- "traefik.http.routers.solidtime-dev-storage-management-https.rule=Host(`storage-management.${NGINX_HOST_NAME}`)"
|
||||
- "traefik.http.routers.solidtime-dev-storage-management-https.service=solidtime-dev-storage-management"
|
||||
- "traefik.http.routers.solidtime-dev-storage-management-https.entrypoints=websecure"
|
||||
- "traefik.http.routers.solidtime-dev-storage-management-https.tls=true"
|
||||
|
||||
minio-create-bucket:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
- minio
|
||||
environment:
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
volumes:
|
||||
- './docker/local/minio:/etc/minio'
|
||||
networks:
|
||||
- sail
|
||||
- reverse-proxy
|
||||
entrypoint: /etc/minio/create_bucket.sh
|
||||
extra_hosts:
|
||||
- "storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}"
|
||||
networks:
|
||||
reverse-proxy:
|
||||
name: "${NETWORK_NAME}"
|
||||
@@ -139,3 +200,5 @@ volumes:
|
||||
driver: local
|
||||
sail-pgsql-test:
|
||||
driver: local
|
||||
sail-minio:
|
||||
driver: local
|
||||
|
||||
10
docker/local/minio/create_bucket.sh
Executable file
10
docker/local/minio/create_bucket.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
|
||||
|
||||
/usr/bin/mc config host add local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
||||
/usr/bin/mc rm -r --force local/${S3_BUCKET};
|
||||
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
|
||||
/usr/bin/mc anonymous set public local/${S3_BUCKET};
|
||||
|
||||
exit 0;
|
||||
@@ -50,4 +50,34 @@ test('test that creating and deleting a new client via the modal works', async (
|
||||
);
|
||||
});
|
||||
|
||||
test('test that archiving and unarchiving clients works', async ({ page }) => {
|
||||
const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||
await page.getByLabel('Client Name').fill(newClientName);
|
||||
|
||||
await page.getByRole('button', { name: 'Create Client' }).nth(1).click();
|
||||
await expect(page.getByText(newClientName)).toBeVisible();
|
||||
|
||||
await page.getByRole('row').first().getByRole('button').click();
|
||||
await Promise.all([
|
||||
page.getByRole('button').getByText('Archive').first().click(),
|
||||
expect(page.getByText(newClientName)).not.toBeVisible(),
|
||||
]);
|
||||
await Promise.all([
|
||||
page.getByRole('tab', { name: 'Archived' }).click(),
|
||||
expect(page.getByText(newClientName)).toBeVisible(),
|
||||
]);
|
||||
|
||||
await page.getByRole('row').first().getByRole('button').click();
|
||||
await Promise.all([
|
||||
page.getByRole('button').getByText('Unarchive').first().click(),
|
||||
expect(page.getByText(newClientName)).not.toBeVisible(),
|
||||
]);
|
||||
await Promise.all([
|
||||
page.getByRole('tab', { name: 'Active' }).click(),
|
||||
expect(page.getByText(newClientName)).toBeVisible(),
|
||||
]);
|
||||
});
|
||||
|
||||
// TODO: Add Name Update Test
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function stoppedTimeEntryResponse(
|
||||
return page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
response.url().includes('/time-entries/') &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
@@ -31,6 +32,7 @@ return [
|
||||
OrganizationNeedsAtLeastOneOwner::KEY => 'Organization needs at least one owner',
|
||||
ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed',
|
||||
ExportException::KEY => 'Export failed, please try again later or contact support',
|
||||
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
|
||||
],
|
||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||
];
|
||||
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.7.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
@@ -1663,6 +1664,19 @@
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
|
||||
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.9.0.tgz",
|
||||
@@ -1673,6 +1687,25 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-table": {
|
||||
"version": "8.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.20.5.tgz",
|
||||
"integrity": "sha512-2xixT3BEgSDw+jOSqPt6ylO/eutDI107t2WdFMVYIZZ45UmTHLySqNriNs0+dMaKR56K5z3t+97P6VuVnI2L+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.20.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-virtual": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.9.0.tgz",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.7.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
|
||||
@@ -37,5 +37,6 @@
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="AUDITING_ENABLED" value="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -26,22 +26,14 @@ watchEffect(async () => {
|
||||
<div>
|
||||
<div
|
||||
v-if="show && message"
|
||||
:class="{
|
||||
'bg-indigo-500': style == 'success',
|
||||
'bg-red-700': style == 'danger',
|
||||
}">
|
||||
<div class="max-w-screen-xl mx-auto py-2 px-3 sm:px-6 lg:px-8">
|
||||
class="bg-secondary border-b border-border-secondary">
|
||||
<div class="mx-auto py-1 px-3 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between flex-wrap">
|
||||
<div class="w-0 flex-1 flex items-center min-w-0">
|
||||
<span
|
||||
class="flex p-2 rounded-lg"
|
||||
:class="{
|
||||
'bg-indigo-600': style == 'success',
|
||||
'bg-red-600': style == 'danger',
|
||||
}">
|
||||
<span class="flex">
|
||||
<svg
|
||||
v-if="style == 'success'"
|
||||
class="h-5 w-5 text-white"
|
||||
class="h-6 w-6 text-text-secondary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -76,13 +68,7 @@ watchEffect(async () => {
|
||||
<div class="shrink-0 sm:ms-3">
|
||||
<button
|
||||
type="button"
|
||||
class="-me-1 flex p-2 rounded-md focus:outline-none sm:-me-2 transition"
|
||||
:class="{
|
||||
'hover:bg-indigo-600 focus:bg-indigo-600':
|
||||
style == 'success',
|
||||
'hover:bg-red-600 focus:bg-red-600':
|
||||
style == 'danger',
|
||||
}"
|
||||
class="-me-1 flex p-2 rounded-md focus:outline-none sm:-me-2 transition hover:bg-tertiary focus:bg-tertiary"
|
||||
aria-label="Dismiss"
|
||||
@click.prevent="show = false">
|
||||
<svg
|
||||
|
||||
139
resources/js/Components/Billing/BillingBanner.vue
Normal file
139
resources/js/Components/Billing/BillingBanner.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import {
|
||||
CheckBadgeIcon,
|
||||
XMarkIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/vue/16/solid';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
daysLeftInTrial,
|
||||
isBillingActivated,
|
||||
isBlocked,
|
||||
isFreePlan,
|
||||
isInTrial,
|
||||
} from '@/utils/billing';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { canManageBilling } from '@/utils/permissions';
|
||||
|
||||
const hideTrialBanner = useSessionStorage(
|
||||
'showTrialBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
|
||||
const hideBlockedBanner = useSessionStorage(
|
||||
'showBlockedBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
const showBlockedBanner = computed(
|
||||
() => isBlocked() && !hideBlockedBanner.value
|
||||
);
|
||||
const hideFreeUpgradeBanner = useSessionStorage(
|
||||
'showFreeUpgradeBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
const showFreeUpgradeBanner = computed(
|
||||
() => isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showTrialBanner"
|
||||
class="bg-accent-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||
<MainContainer class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<CheckBadgeIcon class="w-4 text-white/50"></CheckBadgeIcon>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium">
|
||||
Your trial expires in {{ daysLeftInTrial() }} days.
|
||||
</span>
|
||||
<span class="hidden md:inline">
|
||||
To continue using all features & support the development
|
||||
of solidtime, please upgrade your plan.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link v-if="canManageBilling()" href="/billing">
|
||||
<div
|
||||
class="text-white font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button @click="hideTrialBanner = true" class="p-1">
|
||||
<XMarkIcon
|
||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
<div
|
||||
v-if="showBlockedBanner"
|
||||
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||
<MainContainer class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<XCircleIcon class="w-4 text-white/50"></XCircleIcon>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium">
|
||||
Your organization is currently blocked.
|
||||
</span>
|
||||
<span class="hidden md:inline">
|
||||
Please upgrade to a premium plan or remove all users
|
||||
except the owner to unblock your organization.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link
|
||||
v-if="isBillingActivated() && canManageBilling()"
|
||||
href="/billing">
|
||||
<div
|
||||
class="text-white font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button @click="hideBlockedBanner = true" class="p-1">
|
||||
<XMarkIcon
|
||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
<div
|
||||
v-if="showFreeUpgradeBanner"
|
||||
class="bg-tertiary text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||
<MainContainer class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<XCircleIcon class="w-4 text-white/50"></XCircleIcon>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium">
|
||||
You are currently using the Free Plan.
|
||||
</span>
|
||||
<span class="hidden md:inline">
|
||||
To unlock all premium features & support the development
|
||||
of solidtime, please upgrade your plan.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link
|
||||
v-if="isBillingActivated() && canManageBilling()"
|
||||
href="/billing">
|
||||
<div
|
||||
class="text-white font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button @click="hideFreeUpgradeBanner = true" class="p-1">
|
||||
<XMarkIcon
|
||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -7,6 +7,7 @@ import type { CreateClientBody } from '@/packages/api/src';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue';
|
||||
|
||||
const { createClient } = useClientsStore();
|
||||
const show = defineModel('show', { default: false });
|
||||
@@ -18,6 +19,7 @@ const client = ref<CreateClientBody>({
|
||||
|
||||
async function submit() {
|
||||
await createClient(client.value);
|
||||
client.value.name = '';
|
||||
show.value = false;
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
<template #content>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="col-span-6 sm:col-span-4 flex-1">
|
||||
<InputLabel for="clientName" value="Client Name" />
|
||||
<TextInput
|
||||
id="clientName"
|
||||
ref="clientNameInput"
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
ArchiveBoxIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import type { Client } from '@/packages/api/src';
|
||||
import { canDeleteClients, canUpdateClients } from '@/utils/permissions';
|
||||
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
|
||||
import ClientEditModal from '@/Components/Common/Client/ClientEditModal.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [];
|
||||
edit: [];
|
||||
archive: [];
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
client: Client;
|
||||
}>();
|
||||
const showEditModal = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientEditModal
|
||||
:client="client"
|
||||
v-model:show="showEditModal"></ClientEditModal>
|
||||
<MoreOptionsDropdown :label="'Actions for Client ' + props.client.name">
|
||||
<div class="min-w-[150px]">
|
||||
<button
|
||||
v-if="canUpdateClients()"
|
||||
@click="emit('edit')"
|
||||
@click="showEditModal = true"
|
||||
:aria-label="'Edit Client ' + props.client.name"
|
||||
data-testid="client_edit"
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
@@ -26,6 +36,14 @@ const props = defineProps<{
|
||||
class="w-5 text-icon-active"></PencilSquareIcon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="emit('archive')"
|
||||
v-if="canUpdateClients()"
|
||||
:aria-label="'Archive Client ' + props.client.name"
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
|
||||
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canDeleteClients()"
|
||||
@click="emit('delete')"
|
||||
|
||||
@@ -1,18 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { UserCircleIcon } from '@heroicons/vue/24/solid';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import { type Component, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
||||
import {
|
||||
ChevronUpDownIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/vue/16/solid';
|
||||
import { type Component, computed, h, ref, watchEffect } from 'vue';
|
||||
import { type Client } from '@/packages/api/src';
|
||||
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
||||
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
||||
import { canCreateClients } from '@/utils/permissions';
|
||||
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
|
||||
const props = defineProps<{
|
||||
clients: Client[];
|
||||
}>();
|
||||
const createClient = ref(false);
|
||||
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
useVueTable,
|
||||
createColumnHelper,
|
||||
type SortingState,
|
||||
getSortedRowModel,
|
||||
} from '@tanstack/vue-table';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import ClientEditModal from '@/Components/Common/Client/ClientEditModal.vue';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
import TableCell from '@/Components/TableCell.vue';
|
||||
|
||||
const columnHelper = createColumnHelper<Client>();
|
||||
const { projects } = storeToRefs(useProjectsStore());
|
||||
|
||||
const columns = computed(() => [
|
||||
columnHelper.accessor((row) => row.name, {
|
||||
id: 'name',
|
||||
cell: (info) => info.getValue(),
|
||||
header: () => 'Name',
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: 'projects',
|
||||
sortingFn: (a, b) => {
|
||||
return (
|
||||
projects.value.filter(
|
||||
(projects) => projects.client_id === a.original.id
|
||||
).length -
|
||||
projects.value.filter(
|
||||
(projects) => projects.client_id === b.original.id
|
||||
).length
|
||||
);
|
||||
},
|
||||
cell: (info) =>
|
||||
h('div', {
|
||||
innerHTML:
|
||||
projects.value.filter(
|
||||
(projects) => projects.client_id === info.getValue().id
|
||||
).length + ' Projects',
|
||||
}),
|
||||
header: () => 'Projects',
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
cell: (info) =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex space-x-1 items-center',
|
||||
},
|
||||
[
|
||||
h(CheckCircleIcon, {
|
||||
class: 'w-5',
|
||||
}),
|
||||
h('span', {
|
||||
innerHTML: info.getValue().is_archived
|
||||
? 'Archived'
|
||||
: 'Active',
|
||||
}),
|
||||
]
|
||||
),
|
||||
header: () => 'Status',
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'actions',
|
||||
cell: (info) => {
|
||||
const showEditModal = ref(false);
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex space-x-1 items-center',
|
||||
},
|
||||
[
|
||||
h(ClientEditModal, {
|
||||
client: info.row.original,
|
||||
show: showEditModal.value,
|
||||
}),
|
||||
h(ClientMoreOptionsDropdown, {
|
||||
class: 'w-5',
|
||||
client: info.row.original,
|
||||
onEdit: () => (showEditModal.value = true),
|
||||
onArchive: () => {
|
||||
useClientsStore().updateClient(
|
||||
info.row.original.id,
|
||||
{
|
||||
...info.row.original,
|
||||
is_archived: !info.row.original.is_archived,
|
||||
}
|
||||
);
|
||||
},
|
||||
onDelete: () => {
|
||||
useClientsStore().deleteClient(
|
||||
info.row.original.id
|
||||
);
|
||||
},
|
||||
}),
|
||||
]
|
||||
);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = ref(props.clients);
|
||||
|
||||
watchEffect(() => {
|
||||
data.value = props.clients;
|
||||
});
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return data.value;
|
||||
},
|
||||
onSortingChange: (updaterOrValue) => {
|
||||
sorting.value =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(sorting.value)
|
||||
: updaterOrValue;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
},
|
||||
columns: columns.value,
|
||||
});
|
||||
const sorting = ref<SortingState>([]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,8 +161,44 @@ const createClient = ref(false);
|
||||
<div
|
||||
data-testid="client_table"
|
||||
class="grid min-w-full"
|
||||
style="grid-template-columns: 1fr 150px 80px">
|
||||
<ClientTableHeading></ClientTableHeading>
|
||||
style="grid-template-columns: 1fr 150px 200px 80px">
|
||||
<TableHeading>
|
||||
<TableCell
|
||||
v-for="header in table.getHeaderGroups()[0].headers"
|
||||
:key="header.id"
|
||||
:class="
|
||||
header.column.getCanSort()
|
||||
? 'cursor-pointer select-none'
|
||||
: ''
|
||||
"
|
||||
@click="
|
||||
header.column.getToggleSortingHandler()?.($event)
|
||||
"
|
||||
:cell="header">
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()" />
|
||||
<div class="px-1" v-if="header.column.getCanSort()">
|
||||
<ChevronUpDownIcon
|
||||
class="h-4 text-text-tertiary"
|
||||
v-if="
|
||||
header.column.getIsSorted() === false
|
||||
"></ChevronUpDownIcon>
|
||||
<ChevronDownIcon
|
||||
class="h-4 text-accent-300"
|
||||
v-if="
|
||||
header.column.getIsSorted() === 'desc'
|
||||
"></ChevronDownIcon>
|
||||
<ChevronUpIcon
|
||||
class="h-4 text-accent-300"
|
||||
v-if="
|
||||
header.column.getIsSorted() === 'asc'
|
||||
"></ChevronUpIcon>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableHeading>
|
||||
|
||||
<div
|
||||
class="col-span-2 py-24 text-center"
|
||||
v-if="clients.length === 0">
|
||||
@@ -40,9 +215,16 @@ const createClient = ref(false);
|
||||
>Create your First Client
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<template v-for="client in clients" :key="client.id">
|
||||
<ClientTableRow :client="client"></ClientTableRow>
|
||||
</template>
|
||||
<TableRow v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:cell="cell">
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user