mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Merge pull request #6 from solidtime-io/feature/add_frontend_dashboard
add dashboard frontend
This commit is contained in:
17
.env.ci
17
.env.ci
@@ -1,19 +1,22 @@
|
||||
APP_NAME=Laravel
|
||||
APP_NAME=solidtime
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_FORCE_HTTPS=false
|
||||
SESSION_SECURE_COOKIE=false
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=laravel
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=root
|
||||
DB_CONNECTION=pgsql_test
|
||||
|
||||
DB_TEST_HOST=127.0.0.1
|
||||
DB_TEST_PORT=5432
|
||||
DB_TEST_DATABASE=laravel
|
||||
DB_TEST_USERNAME=root
|
||||
DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
CACHE_DRIVER=file
|
||||
|
||||
10
.env.example
10
.env.example
@@ -4,6 +4,7 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://solidtime.test
|
||||
APP_FORCE_HTTPS=true
|
||||
SESSION_SECURE_COOKIE=true
|
||||
|
||||
SUPER_ADMINS=admin@example.com
|
||||
|
||||
@@ -12,12 +13,19 @@ LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
|
||||
DB_HOST=pgsql
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=laravel
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=root
|
||||
|
||||
DB_TEST_HOST=pgsql_test
|
||||
DB_TEST_PORT=5432
|
||||
DB_TEST_DATABASE=laravel
|
||||
DB_TEST_USERNAME=root
|
||||
DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
CACHE_DRIVER=file
|
||||
FILESYSTEM_DISK=local
|
||||
@@ -37,7 +45,7 @@ MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
require("@rushstack/eslint-patch/modern-module-resolution")
|
||||
|
||||
module.exports = {
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'@vue/eslint-config-typescript/recommended',
|
||||
'@vue/eslint-config-prettier'
|
||||
],
|
||||
extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
}
|
||||
|
||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -5,7 +5,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
pgsql:
|
||||
pgsql_test:
|
||||
image: postgres:15
|
||||
env:
|
||||
PGPASSWORD: 'root'
|
||||
|
||||
24
.github/workflows/playwright.yml
vendored
24
.github/workflows/playwright.yml
vendored
@@ -11,6 +11,20 @@ jobs:
|
||||
services:
|
||||
mailpit:
|
||||
image: 'axllent/mailpit:latest'
|
||||
pgsql_test:
|
||||
image: postgres:15
|
||||
env:
|
||||
PGPASSWORD: 'root'
|
||||
POSTGRES_DB: 'laravel'
|
||||
POSTGRES_USER: 'root'
|
||||
POSTGRES_PASSWORD: 'root'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
@@ -24,20 +38,18 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
coverage: none
|
||||
|
||||
- name: Run composer install
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- name: Create SQLite database
|
||||
run: touch database/database.sqlite
|
||||
|
||||
- name: Prepare Laravel Application
|
||||
run: |
|
||||
cp .env.ci .env
|
||||
php artisan key:generate
|
||||
php artisan migrate --seed
|
||||
php artisan passport:keys
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -59,7 +71,7 @@ jobs:
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
name: test-results
|
||||
path: test-results/
|
||||
retention-days: 30
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,3 +26,4 @@ yarn-error.log
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/coverage
|
||||
/extensions/*
|
||||
|
||||
16
README.md
16
README.md
@@ -18,6 +18,8 @@ cp .env.example .env
|
||||
|
||||
./vendor/bin/sail artisan migrate:fresh --seed
|
||||
|
||||
./vendor/bin/sail php artisan passport:install
|
||||
|
||||
./vendor/bin/sail npm install
|
||||
|
||||
./vendor/bin/sail npm run build
|
||||
@@ -36,6 +38,7 @@ Add the following entry to your `/etc/hosts`
|
||||
```
|
||||
127.0.0.1 solidtime.test
|
||||
127.0.0.1 playwright.solidtime.test
|
||||
127.0.0.1 mail.solidtime.test
|
||||
```
|
||||
|
||||
## Running E2E Tests
|
||||
@@ -52,6 +55,19 @@ npx playwright install
|
||||
npx playwright codegen solidtime.test
|
||||
```
|
||||
|
||||
## E2E Troubleshooting
|
||||
|
||||
If the E2E tests are not working consistently and fail with a timeout during the authentication, you might want to delete the `test-results/.auth` directory to force new test accounts to be created.
|
||||
|
||||
## Generate ZOD Client
|
||||
|
||||
The Zodius HTTP client is generated using the following command:
|
||||
|
||||
```bash
|
||||
|
||||
npm run generate:zod
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
|
||||
|
||||
@@ -6,9 +6,12 @@ namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
@@ -20,12 +23,27 @@ class CreateNewUser implements CreatesNewUsers
|
||||
* Create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->where('is_placeholder', '=', false);
|
||||
}),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
||||
])->validate();
|
||||
|
||||
@@ -8,8 +8,11 @@ use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
@@ -21,21 +24,24 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
/**
|
||||
* Add a new team member to the given team.
|
||||
*/
|
||||
public function add(User $user, Organization $organization, string $email, ?string $role = null): void
|
||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
Gate::forUser($user)->authorize('addTeamMember', $organization);
|
||||
Gate::forUser($owner)->authorize('addTeamMember', $organization);
|
||||
|
||||
$this->validate($organization, $email, $role);
|
||||
|
||||
$newTeamMember = Jetstream::findUserByEmailOrFail($email);
|
||||
$newOrganizationMember = User::query()
|
||||
->where('email', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->firstOrFail();
|
||||
|
||||
AddingTeamMember::dispatch($organization, $newTeamMember);
|
||||
AddingTeamMember::dispatch($organization, $newOrganizationMember);
|
||||
|
||||
$organization->users()->attach(
|
||||
$newTeamMember, ['role' => $role]
|
||||
$newOrganizationMember, ['role' => $role]
|
||||
);
|
||||
|
||||
TeamMemberAdded::dispatch($organization, $newTeamMember);
|
||||
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,9 +52,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
Validator::make([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
], $this->rules(), [
|
||||
'email.exists' => __('We were unable to find a registered user with this email address.'),
|
||||
])->after(
|
||||
], $this->rules())->after(
|
||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||
)->validateWithBag('addTeamMember');
|
||||
}
|
||||
@@ -56,12 +60,18 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
/**
|
||||
* Get the validation rules for adding a team member.
|
||||
*
|
||||
* @return array<string, array<Rule|string>>
|
||||
* @return array<string, array<ValidationRule|Rule|string>>
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return array_filter([
|
||||
'email' => ['required', 'email', 'exists:users'],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
(new ExistsEloquent(User::class, 'email', function (Builder $builder) {
|
||||
return $builder->where('is_placeholder', '=', false);
|
||||
}))->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||
],
|
||||
'role' => Jetstream::hasRoles()
|
||||
? ['required', 'string', new Role]
|
||||
: null,
|
||||
@@ -75,7 +85,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
{
|
||||
return function ($validator) use ($team, $email) {
|
||||
$validator->errors()->addIf(
|
||||
$team->hasUserWithEmail($email),
|
||||
$team->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
__('This user already belongs to the team.')
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
||||
|
||||
InvitingTeamMember::dispatch($organization, $email, $role);
|
||||
|
||||
/** @var OrganizationInvitation $invitation */
|
||||
$invitation = $organization->teamInvitations()->create([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
@@ -50,9 +51,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
||||
Validator::make([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
], $this->rules($organization), [
|
||||
'email.unique' => __('This user has already been invited to the team.'),
|
||||
])->after(
|
||||
], $this->rules($organization))->after(
|
||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||
)->validateWithBag('addTeamMember');
|
||||
}
|
||||
@@ -68,10 +67,10 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
|
||||
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
|
||||
/** @var Builder<OrganizationInvitation> $builder */
|
||||
return $builder->whereBelongsTo($organization, 'organization');
|
||||
}),
|
||||
}))->withMessage(__('This user has already been invited to the team.')),
|
||||
],
|
||||
'role' => Jetstream::hasRoles()
|
||||
? ['required', 'string', new Role]
|
||||
@@ -86,7 +85,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
||||
{
|
||||
return function ($validator) use ($organization, $email) {
|
||||
$validator->errors()->addIf(
|
||||
$organization->hasUserWithEmail($email),
|
||||
$organization->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
__('This user already belongs to the team.')
|
||||
);
|
||||
|
||||
50
app/Exceptions/Api/ApiException.php
Normal file
50
app/Exceptions/Api/ApiException.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use LogicException;
|
||||
|
||||
abstract class ApiException extends Exception
|
||||
{
|
||||
public const string KEY = 'api_exception';
|
||||
|
||||
/**
|
||||
* Render the exception into an HTTP response.
|
||||
*/
|
||||
public function render(Request $request): JsonResponse
|
||||
{
|
||||
return response()
|
||||
->json([
|
||||
'error' => true,
|
||||
'key' => $this->getKey(),
|
||||
'message' => $this->getTranslatedMessage(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key for the exception.
|
||||
*/
|
||||
public function getKey(): string
|
||||
{
|
||||
$key = static::KEY;
|
||||
|
||||
if ($key === ApiException::KEY) {
|
||||
throw new LogicException('API exceptions need the KEY constant defined.');
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translated message for the exception.
|
||||
*/
|
||||
public function getTranslatedMessage(): string
|
||||
{
|
||||
return __('exceptions.api.'.$this->getKey());
|
||||
}
|
||||
}
|
||||
10
app/Exceptions/Api/TimeEntryStillRunningApiException.php
Normal file
10
app/Exceptions/Api/TimeEntryStillRunningApiException.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class TimeEntryStillRunningApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'time_entry_still_running';
|
||||
}
|
||||
10
app/Exceptions/Api/UserNotPlaceholderApiException.php
Normal file
10
app/Exceptions/Api/UserNotPlaceholderApiException.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class UserNotPlaceholderApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_not_placeholder';
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ApiException extends Exception
|
||||
{
|
||||
/**
|
||||
* Render the exception into an HTTP response.
|
||||
*/
|
||||
public function render(Request $request): JsonResponse
|
||||
{
|
||||
return response()
|
||||
->json([
|
||||
'error' => true,
|
||||
'message' => $this->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
class TimeEntryStillRunning extends ApiException
|
||||
{
|
||||
}
|
||||
@@ -6,6 +6,8 @@ namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ClientResource\Pages;
|
||||
use App\Models\Client;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
@@ -26,7 +28,14 @@ class ClientResource extends Resource
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
//
|
||||
TextInput::make('name')
|
||||
->label('Name')
|
||||
->required(),
|
||||
Select::make('organization_id')
|
||||
->relationship(name: 'organization', titleAttribute: 'name')
|
||||
->label('Organization')
|
||||
->searchable(['name'])
|
||||
->required(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,21 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\OrganizationResource\Pages;
|
||||
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
|
||||
use App\Models\Organization;
|
||||
use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
use App\Service\Import\ImportService;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class OrganizationResource extends Resource
|
||||
{
|
||||
@@ -60,6 +69,55 @@ class OrganizationResource extends Resource
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Action::make('Import')
|
||||
->icon('heroicon-o-inbox-arrow-down')
|
||||
->action(function (Organization $record, array $data) {
|
||||
try {
|
||||
/** @var ReportDto $report */
|
||||
$report = app(ImportService::class)->import(
|
||||
$record,
|
||||
$data['type'],
|
||||
Storage::disk(config('filament.default_filesystem_disk'))->get($data['file'])
|
||||
);
|
||||
Notification::make()
|
||||
->title('Import successful')
|
||||
->success()
|
||||
->body(
|
||||
'Imported time entries: '.$report->timeEntriesCreated.'<br>'.
|
||||
'Imported clients: '.$report->clientsCreated.'<br>'.
|
||||
'Imported projects: '.$report->projectsCreated.'<br>'.
|
||||
'Imported tasks: '.$report->tasksCreated.'<br>'.
|
||||
'Imported tags: '.$report->tagsCreated.'<br>'.
|
||||
'Imported users: '.$report->usersCreated
|
||||
)
|
||||
->persistent()
|
||||
->send();
|
||||
} catch (ImportException $exception) {
|
||||
report($exception);
|
||||
Notification::make()
|
||||
->title('Import failed, changes rolled back')
|
||||
->danger()
|
||||
->body('Message: '.$exception->getMessage())
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
})
|
||||
->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
|
||||
->form([
|
||||
Forms\Components\FileUpload::make('file')
|
||||
->label('File')
|
||||
->required(),
|
||||
Select::make('type')
|
||||
->required()
|
||||
->options(function (): array {
|
||||
$select = [];
|
||||
foreach (app(ImporterProvider::class)->getImporterKeys() as $key) {
|
||||
$select[$key] = $key;
|
||||
}
|
||||
|
||||
return $select;
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
@@ -71,7 +129,7 @@ class OrganizationResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
UsersRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class UsersRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'users';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name'),
|
||||
Tables\Columns\TextColumn::make('role'),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TaskResource\Pages;
|
||||
use App\Models\Task;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class TaskResource extends Resource
|
||||
@@ -25,7 +28,18 @@ class TaskResource extends Resource
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
//
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Select::make('project_id')
|
||||
->relationship(name: 'project', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->required(),
|
||||
Select::make('organization_id')
|
||||
->relationship(name: 'organization', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->required(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,7 +60,9 @@ class TaskResource extends Resource
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
SelectFilter::make('organization')
|
||||
->relationship('organization', 'name')
|
||||
->searchable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->actions([
|
||||
|
||||
@@ -14,6 +14,7 @@ use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class TimeEntryResource extends Resource
|
||||
@@ -67,6 +68,7 @@ class TimeEntryResource extends Resource
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('description')
|
||||
->searchable()
|
||||
->label('Description'),
|
||||
TextColumn::make('user.email')
|
||||
->label('User'),
|
||||
@@ -89,7 +91,9 @@ class TimeEntryResource extends Resource
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
SelectFilter::make('organization')
|
||||
->relationship('organization', 'name')
|
||||
->searchable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->actions([
|
||||
|
||||
@@ -5,12 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\UserResource\Pages;
|
||||
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
|
||||
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
|
||||
use App\Models\User;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UserResource extends Resource
|
||||
{
|
||||
@@ -41,10 +45,11 @@ class UserResource extends Resource
|
||||
->label('Email')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('password')
|
||||
->label('Password')
|
||||
->required()
|
||||
TextInput::make('password')
|
||||
->password()
|
||||
->dehydrateStateUsing(fn ($state) => Hash::make($state))
|
||||
->dehydrated(fn ($state) => filled($state))
|
||||
->required(fn (string $context): bool => $context === 'create')
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
@@ -77,7 +82,8 @@ class UserResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
OwnedOrganizationsRelationManager::class,
|
||||
OrganizationsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,23 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->record;
|
||||
|
||||
$user->ownedTeams()->save(Organization::forceCreate([
|
||||
'user_id' => $user->id,
|
||||
'name' => explode(' ', $user->name, 2)[0]."'s Organization",
|
||||
'personal_team' => true,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\UserResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class OrganizationsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'organizations';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name'),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\UserResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class OwnedOrganizationsRelationManager extends RelationManager
|
||||
{
|
||||
protected static ?string $title = 'Owned Organizations';
|
||||
|
||||
protected static string $relationship = 'ownedTeams';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name'),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
]);
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Api/V1/ImportController.php
Normal file
63
app/Http/Controllers/Api/V1/ImportController.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Requests\V1\Import\ImportRequest;
|
||||
use App\Models\Organization;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\ImportService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ImportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Import data into the organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function import(Organization $organization, ImportRequest $request, ImportService $importService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'import');
|
||||
|
||||
try {
|
||||
$report = $importService->import(
|
||||
$organization,
|
||||
$request->input('type'),
|
||||
$request->input('data')
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
/** @var array{
|
||||
* clients: array{
|
||||
* created: int,
|
||||
* },
|
||||
* projects: array{
|
||||
* created: int,
|
||||
* },
|
||||
* tasks: array{
|
||||
* created: int,
|
||||
* },
|
||||
* time-entries: array{
|
||||
* created: int,
|
||||
* },
|
||||
* tags: array{
|
||||
* created: int,
|
||||
* },
|
||||
* users: array{
|
||||
* created: int,
|
||||
* }
|
||||
* } $report Import report */
|
||||
'report' => $report->toArray(),
|
||||
], 200);
|
||||
} catch (ImportException $exception) {
|
||||
report($exception);
|
||||
|
||||
return new JsonResponse([
|
||||
'message' => $exception->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ class ProjectController extends Controller
|
||||
* Get projects
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getProjects
|
||||
*/
|
||||
public function index(Organization $organization): JsonResource
|
||||
{
|
||||
@@ -43,6 +45,8 @@ class ProjectController extends Controller
|
||||
* Get project
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getProject
|
||||
*/
|
||||
public function show(Organization $organization, Project $project): JsonResource
|
||||
{
|
||||
@@ -57,6 +61,8 @@ class ProjectController extends Controller
|
||||
* Create project
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId createProject
|
||||
*/
|
||||
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
|
||||
{
|
||||
@@ -75,6 +81,8 @@ class ProjectController extends Controller
|
||||
* Update project
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId updateProject
|
||||
*/
|
||||
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request): JsonResource
|
||||
{
|
||||
@@ -90,6 +98,8 @@ class ProjectController extends Controller
|
||||
* Delete project
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId deleteProject
|
||||
*/
|
||||
public function destroy(Organization $organization, Project $project): JsonResponse
|
||||
{
|
||||
|
||||
@@ -27,6 +27,8 @@ class TagController extends Controller
|
||||
* Get tags
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getTags
|
||||
*/
|
||||
public function index(Organization $organization): TagCollection
|
||||
{
|
||||
@@ -44,6 +46,8 @@ class TagController extends Controller
|
||||
* Create tag
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId createTag
|
||||
*/
|
||||
public function store(Organization $organization, TagStoreRequest $request): TagResource
|
||||
{
|
||||
@@ -61,6 +65,8 @@ class TagController extends Controller
|
||||
* Update tag
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId updateTag
|
||||
*/
|
||||
public function update(Organization $organization, Tag $tag, TagUpdateRequest $request): TagResource
|
||||
{
|
||||
@@ -76,6 +82,8 @@ class TagController extends Controller
|
||||
* Delete tag
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId deleteTag
|
||||
*/
|
||||
public function destroy(Organization $organization, Tag $tag): JsonResponse
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\TimeEntryStillRunning;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
|
||||
@@ -32,6 +32,8 @@ class TimeEntryController extends Controller
|
||||
* Get time entries
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getTimeEntries
|
||||
*/
|
||||
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
||||
{
|
||||
@@ -102,7 +104,9 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Create time entry
|
||||
*
|
||||
* @throws AuthorizationException|TimeEntryStillRunning
|
||||
* @throws AuthorizationException|TimeEntryStillRunningApiException
|
||||
*
|
||||
* @operationId createTimeEntry
|
||||
*/
|
||||
public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource
|
||||
{
|
||||
@@ -114,13 +118,12 @@ class TimeEntryController extends Controller
|
||||
|
||||
if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) {
|
||||
// TODO: API documentation
|
||||
// TODO: Create concept for api exceptions
|
||||
throw new TimeEntryStillRunning('User already has an active time entry');
|
||||
throw new TimeEntryStillRunningApiException();
|
||||
}
|
||||
|
||||
$timeEntry = new TimeEntry();
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->description = $request->get('description', '');
|
||||
$timeEntry->description = $request->get('description') ?? '';
|
||||
$timeEntry->organization()->associate($organization);
|
||||
$timeEntry->save();
|
||||
|
||||
@@ -131,6 +134,8 @@ class TimeEntryController extends Controller
|
||||
* Update time entry
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId updateTimeEntry
|
||||
*/
|
||||
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
||||
{
|
||||
@@ -141,6 +146,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->description = $request->get('description', $timeEntry->description) ?? '';
|
||||
$timeEntry->save();
|
||||
|
||||
return new TimeEntryResource($timeEntry);
|
||||
@@ -150,6 +156,8 @@ class TimeEntryController extends Controller
|
||||
* Delete time entry
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId deleteTimeEntry
|
||||
*/
|
||||
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
||||
{
|
||||
|
||||
56
app/Http/Controllers/Api/V1/UserController.php
Normal file
56
app/Http/Controllers/Api/V1/UserController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
use App\Http\Requests\V1\User\UserIndexRequest;
|
||||
use App\Http\Resources\V1\User\UserCollection;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all users in an organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function index(Organization $organization, UserIndexRequest $request): UserCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'users:view');
|
||||
|
||||
$users = $organization->users()
|
||||
->paginate();
|
||||
|
||||
return UserCollection::make($users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a placeholder user to become a real user in the organization
|
||||
*
|
||||
* @throws AuthorizationException|UserNotPlaceholderApiException
|
||||
*/
|
||||
public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'users:invite-placeholder');
|
||||
|
||||
if (! $user->is_placeholder) {
|
||||
throw new UserNotPlaceholderApiException();
|
||||
}
|
||||
|
||||
app(InvitesTeamMembers::class)->invite(
|
||||
$request->user(),
|
||||
$organization,
|
||||
$user->email,
|
||||
'employee'
|
||||
);
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class ValidateSignature extends Middleware
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
protected array $except = [
|
||||
// 'fbclid',
|
||||
// 'utm_campaign',
|
||||
// 'utm_content',
|
||||
|
||||
30
app/Http/Requests/V1/Import/ImportRequest.php
Normal file
30
app/Http/Requests/V1/Import/ImportRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Import;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ImportRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => [
|
||||
'required',
|
||||
'string',
|
||||
],
|
||||
'data' => [
|
||||
'required',
|
||||
'string',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Project;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Rules\ColorRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -25,6 +26,7 @@ class ProjectStoreRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
@@ -34,6 +36,7 @@ class ProjectStoreRequest extends FormRequest
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
new ColorRule(),
|
||||
],
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Project;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Rules\ColorRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -25,6 +26,7 @@ class ProjectUpdateRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
@@ -33,6 +35,7 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
new ColorRule(),
|
||||
],
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
|
||||
@@ -18,6 +18,7 @@ class TagStoreRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
|
||||
@@ -18,6 +18,7 @@ class TagUpdateRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
|
||||
@@ -30,10 +30,7 @@ class TimeEntryIndexRequest extends FormRequest
|
||||
'uuid',
|
||||
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->whereHas('organizations', function (Builder $builder) {
|
||||
/** @var Builder<Organization> $builder */
|
||||
return $builder->whereKey($this->organization->getKey());
|
||||
});
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
}),
|
||||
],
|
||||
// Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31)
|
||||
@@ -51,7 +48,8 @@ class TimeEntryIndexRequest extends FormRequest
|
||||
],
|
||||
// Filter only time entries that are active (have no end date, are still running)
|
||||
'active' => [
|
||||
'boolean',
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
// Limit the number of returned time entries
|
||||
'limit' => [
|
||||
|
||||
@@ -33,10 +33,7 @@ class TimeEntryStoreRequest extends FormRequest
|
||||
'uuid',
|
||||
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->whereHas('organizations', function (Builder $builder) {
|
||||
/** @var Builder<Organization> $builder */
|
||||
return $builder->whereKey($this->organization->getKey());
|
||||
});
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
}),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
@@ -64,7 +61,7 @@ class TimeEntryStoreRequest extends FormRequest
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
'max:500',
|
||||
],
|
||||
// List of tag IDs
|
||||
'tags' => [
|
||||
|
||||
@@ -42,7 +42,7 @@ class TimeEntryUpdateRequest extends FormRequest
|
||||
],
|
||||
// End of time entry (ISO 8601 format, UTC timezone)
|
||||
'end' => [
|
||||
'required',
|
||||
'present',
|
||||
'nullable',
|
||||
'date', // TODO
|
||||
'after:start',
|
||||
@@ -51,7 +51,7 @@ class TimeEntryUpdateRequest extends FormRequest
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
'max:500',
|
||||
],
|
||||
// List of tag IDs
|
||||
'tags' => [
|
||||
|
||||
26
app/Http/Requests/V1/User/UserIndexRequest.php
Normal file
26
app/Http/Requests/V1/User/UserIndexRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\User;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
*/
|
||||
class UserIndexRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ class TimeEntryResource extends BaseResource
|
||||
/**
|
||||
* @var string|null $end End of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
|
||||
*/
|
||||
'end' => $this->formatDateTime($this->resource->start),
|
||||
/** @var int $duration Duration of time entry in seconds */
|
||||
'end' => $this->formatDateTime($this->resource->end),
|
||||
/** @var int|null $duration Duration of time entry in seconds */
|
||||
'duration' => $this->resource->getDuration()?->seconds,
|
||||
/** @var string|null $description Description of time entry */
|
||||
'description' => $this->resource->description,
|
||||
@@ -42,7 +42,7 @@ class TimeEntryResource extends BaseResource
|
||||
/** @var string $user_id ID of user */
|
||||
'user_id' => $this->resource->user_id,
|
||||
/** @var array<string> $tags List of tag IDs */
|
||||
'tags' => $this->resource->tags,
|
||||
'tags' => $this->resource->tags ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
17
app/Http/Resources/V1/User/UserCollection.php
Normal file
17
app/Http/Resources/V1/User/UserCollection.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\User;
|
||||
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class UserCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = UserResource::class;
|
||||
}
|
||||
40
app/Http/Resources/V1/User/UserResource.php
Normal file
40
app/Http/Resources/V1/User/UserResource.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\User;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Membership;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property User $resource
|
||||
*/
|
||||
class UserResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
/** @var Membership $membership */
|
||||
$membership = $this->resource->getRelationValue('membership');
|
||||
|
||||
return [
|
||||
/** @var string $id ID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string $email Email */
|
||||
'email' => $this->resource->email,
|
||||
/** @var string $role Role */
|
||||
'role' => $membership->role,
|
||||
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
|
||||
'is_placeholder' => $this->resource->is_placeholder,
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Listeners/RemovePlaceholder.php
Normal file
30
app/Listeners/RemovePlaceholder.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Service\UserService;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class RemovePlaceholder
|
||||
{
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(TeamMemberAdded $event): void
|
||||
{
|
||||
/** @var UserService $userService */
|
||||
$userService = app(UserService::class);
|
||||
$placeholders = User::query()
|
||||
->where('is_placeholder', '=', true)
|
||||
->where('email', '=', $event->user->email)
|
||||
->belongsToOrganization($event->team)
|
||||
->get();
|
||||
|
||||
foreach ($placeholders as $placeholder) {
|
||||
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,15 @@ declare(strict_types=1);
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\OrganizationFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Laravel\Jetstream\Events\TeamCreated;
|
||||
use Laravel\Jetstream\Events\TeamDeleted;
|
||||
use Laravel\Jetstream\Events\TeamUpdated;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Laravel\Jetstream\Team as JetstreamTeam;
|
||||
|
||||
/**
|
||||
@@ -18,6 +21,8 @@ use Laravel\Jetstream\Team as JetstreamTeam;
|
||||
* @property string $name
|
||||
* @property bool $personal_team
|
||||
* @property User $owner
|
||||
* @property Collection<User> $users
|
||||
* @property Collection<string, User> $realUsers
|
||||
*
|
||||
* @method HasMany<OrganizationInvitation> teamInvitations()
|
||||
* @method static OrganizationFactory factory()
|
||||
@@ -57,4 +62,43 @@ class Organization extends JetstreamTeam
|
||||
'updated' => TeamUpdated::class,
|
||||
'deleted' => TeamDeleted::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all the non-placeholder users of the organization including its owner.
|
||||
*
|
||||
* @return Collection<string, User>
|
||||
*/
|
||||
public function allRealUsers(): Collection
|
||||
{
|
||||
return $this->realUsers->merge([$this->owner]);
|
||||
}
|
||||
|
||||
public function hasRealUserWithEmail(string $email): bool
|
||||
{
|
||||
return $this->allRealUsers()->contains(function (User $user) use ($email): bool {
|
||||
return $user->email === $email;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the users that belong to the team.
|
||||
*
|
||||
* @return BelongsToMany<User>
|
||||
*/
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel())
|
||||
->withPivot('role')
|
||||
->withTimestamps()
|
||||
->as('membership');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<User>
|
||||
*/
|
||||
public function realUsers(): BelongsToMany
|
||||
{
|
||||
return $this->users()
|
||||
->where('is_placeholder', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace App\Models;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
@@ -21,9 +23,16 @@ use Laravel\Passport\HasApiTokens;
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property string|null $email_verified_at
|
||||
* @property string|null $password
|
||||
* @property bool $is_placeholder
|
||||
* @property Collection<Organization> $organizations
|
||||
* @property Collection<TimeEntry> $timeEntries
|
||||
*
|
||||
* @method HasMany<Organization> ownedTeams()
|
||||
* @method static UserFactory factory()
|
||||
* @method static Builder<User> query()
|
||||
* @method Builder<User> belongsToOrganization(Organization $organization)
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
@@ -64,8 +73,11 @@ class User extends Authenticatable
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'email' => 'string',
|
||||
'email_verified_at' => 'datetime',
|
||||
'is_admin' => 'boolean',
|
||||
'is_placeholder' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -94,4 +106,27 @@ class User extends Authenticatable
|
||||
->withTimestamps()
|
||||
->as('membership');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TimeEntry>
|
||||
*/
|
||||
public function timeEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(TimeEntry::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<User> $builder
|
||||
* @return Builder<User>
|
||||
*/
|
||||
public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder
|
||||
{
|
||||
return $builder->where(function (Builder $builder) use ($organization): Builder {
|
||||
return $builder->whereHas('organizations', function (Builder $query) use ($organization): void {
|
||||
$query->whereKey($organization->getKey());
|
||||
})->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void {
|
||||
$query->whereKey($organization->getKey());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
Model::preventLazyLoading(! $this->app->isProduction());
|
||||
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
|
||||
Model::preventAccessingMissingAttributes(! $this->app->isProduction());
|
||||
Relation::enforceMorphMap([
|
||||
'membership' => Membership::class,
|
||||
'organization' => Organization::class,
|
||||
@@ -74,6 +75,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
if (config('app.force_https', false) || App::isProduction()) {
|
||||
URL::forceScheme('https');
|
||||
request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\RemovePlaceholder;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -20,6 +21,9 @@ class EventServiceProvider extends ServiceProvider
|
||||
Registered::class => [
|
||||
SendEmailVerificationNotification::class,
|
||||
],
|
||||
TeamMemberAdded::class => [
|
||||
RemovePlaceholder::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,9 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'import',
|
||||
'users:invite-placeholder',
|
||||
'users:view',
|
||||
])->description('Administrator users can perform any action.');
|
||||
|
||||
Jetstream::role('manager', 'Manager', [
|
||||
@@ -94,7 +97,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'organizations:view',
|
||||
])->description('Editor users have the ability to read, create, and update.');
|
||||
'users:view',
|
||||
])->description('Managers have the ability to read, create, and update their own time entries as well as those of their team.');
|
||||
|
||||
Jetstream::role('employee', 'Employee', [
|
||||
'projects:view',
|
||||
@@ -104,6 +108,9 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'organizations:view',
|
||||
])->description('Editor users have the ability to read, create, and update.');
|
||||
])->description('Employees have the ability to read, create, and update their own time entries.');
|
||||
|
||||
Jetstream::role('placeholder', 'Placeholder', [
|
||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ class RouteServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
if (! $this->app->isProduction()) {
|
||||
return Limit::none();
|
||||
}
|
||||
|
||||
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
|
||||
|
||||
32
app/Rules/ColorRule.php
Normal file
32
app/Rules/ColorRule.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Service\ColorService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class ColorRule implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.string'));
|
||||
|
||||
return;
|
||||
}
|
||||
if (! app(ColorService::class)->isValid($value)) {
|
||||
$fail(__('validation.color'));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/Service/ColorService.php
Normal file
45
app/Service/ColorService.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
class ColorService
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private const array COLORS = [
|
||||
'#ef5350',
|
||||
'#ec407a',
|
||||
'#ab47bc',
|
||||
'#7e57c2',
|
||||
'#5c6bc0',
|
||||
'#42a5f5',
|
||||
'#29b6f6',
|
||||
'#26c6da',
|
||||
'#26a69a',
|
||||
'#66bb6a',
|
||||
'#9ccc65',
|
||||
'#d4e157',
|
||||
'#ffee58',
|
||||
'#ffca28',
|
||||
'#ffa726',
|
||||
'#ff7043',
|
||||
'#8d6e63',
|
||||
'#bdbdbd',
|
||||
'#78909c',
|
||||
];
|
||||
|
||||
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
|
||||
|
||||
public function getRandomColor(): string
|
||||
{
|
||||
return self::COLORS[array_rand(self::COLORS)];
|
||||
}
|
||||
|
||||
public function isValid(string $color): bool
|
||||
{
|
||||
return preg_match(self::VALID_REGEX, $color) === 1;
|
||||
}
|
||||
}
|
||||
211
app/Service/Import/ImportDatabaseHelper.php
Normal file
211
app/Service/Import/ImportDatabaseHelper.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import;
|
||||
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* @template TModel of Model
|
||||
*/
|
||||
class ImportDatabaseHelper
|
||||
{
|
||||
/**
|
||||
* @var class-string<TModel>
|
||||
*/
|
||||
private string $model;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private array $identifiers;
|
||||
|
||||
/**
|
||||
* @var array<string, string>|null
|
||||
*/
|
||||
private ?array $mapIdentifierToKey = null;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $mapExternalIdentifierToInternalIdentifier = [];
|
||||
|
||||
private bool $attachToExisting;
|
||||
|
||||
private ?Closure $queryModifier;
|
||||
|
||||
private ?Closure $afterCreate;
|
||||
|
||||
private int $createdCount;
|
||||
|
||||
/**
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
private array $validate;
|
||||
|
||||
/**
|
||||
* @param class-string<TModel> $model
|
||||
* @param array<string> $identifiers
|
||||
* @param array<string, array<int, string>> $validate
|
||||
*/
|
||||
public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null, array $validate = [])
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->identifiers = $identifiers;
|
||||
$this->attachToExisting = $attachToExisting;
|
||||
$this->queryModifier = $queryModifier;
|
||||
$this->afterCreate = $afterCreate;
|
||||
$this->createdCount = 0;
|
||||
$this->validate = $validate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
private function getModelInstance(): Builder
|
||||
{
|
||||
return (new $this->model)->query();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $identifierData
|
||||
* @param array<string, mixed> $createValues
|
||||
*/
|
||||
private function createEntity(array $identifierData, array $createValues, ?string $externalIdentifier): string
|
||||
{
|
||||
$data = array_merge($identifierData, $createValues);
|
||||
|
||||
$validator = Validator::make($data, $this->validate);
|
||||
if ($validator->fails()) {
|
||||
throw new ImportException('Invalid data: '.implode(', ', $validator->errors()->all()));
|
||||
}
|
||||
|
||||
$model = new $this->model();
|
||||
foreach ($data as $key => $value) {
|
||||
$model->{$key} = $value;
|
||||
}
|
||||
$model->save();
|
||||
|
||||
if ($this->afterCreate !== null) {
|
||||
($this->afterCreate)($model);
|
||||
}
|
||||
|
||||
$hash = $this->getHash($identifierData);
|
||||
$this->mapIdentifierToKey[$hash] = $model->getKey();
|
||||
$this->createdCount++;
|
||||
|
||||
if ($externalIdentifier !== null) {
|
||||
$this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash;
|
||||
}
|
||||
|
||||
return $model->getKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function getHash(array $data): string
|
||||
{
|
||||
$jsonData = json_encode($data);
|
||||
if ($jsonData === false) {
|
||||
throw new \RuntimeException('Failed to encode data to JSON');
|
||||
}
|
||||
|
||||
return md5($jsonData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $identifierData
|
||||
* @param array<string, mixed> $createValues
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
public function getKey(array $identifierData, array $createValues = [], ?string $externalIdentifier = null): string
|
||||
{
|
||||
$this->checkMap();
|
||||
|
||||
$this->validateIdentifierData($identifierData);
|
||||
|
||||
$hash = $this->getHash($identifierData);
|
||||
if ($this->attachToExisting) {
|
||||
$key = $this->mapIdentifierToKey[$hash] ?? null;
|
||||
if ($key !== null) {
|
||||
if ($externalIdentifier !== null) {
|
||||
$this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash;
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
return $this->createEntity($identifierData, $createValues, $externalIdentifier);
|
||||
} else {
|
||||
throw new \RuntimeException('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $identifierData
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function validateIdentifierData(array $identifierData): void
|
||||
{
|
||||
if (array_keys($identifierData) !== $this->identifiers) {
|
||||
throw new ImportException('Invalid identifier data');
|
||||
}
|
||||
}
|
||||
|
||||
public function getKeyByExternalIdentifier(string $externalIdentifier): ?string
|
||||
{
|
||||
$hash = $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] ?? null;
|
||||
if ($hash === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->mapIdentifierToKey[$hash] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getExternalIds(): array
|
||||
{
|
||||
// Note: Otherwise the external ids are integers
|
||||
return array_map(fn ($value) => (string) $value, array_keys($this->mapExternalIdentifierToInternalIdentifier));
|
||||
}
|
||||
|
||||
private function checkMap(): void
|
||||
{
|
||||
if ($this->mapIdentifierToKey === null) {
|
||||
$select = $this->identifiers;
|
||||
$select[] = (new $this->model())->getKeyName();
|
||||
$builder = $this->getModelInstance();
|
||||
|
||||
if ($this->queryModifier !== null) {
|
||||
$builder = ($this->queryModifier)($builder);
|
||||
}
|
||||
|
||||
$databaseEntries = $builder->select($select)
|
||||
->get();
|
||||
$this->mapIdentifierToKey = [];
|
||||
foreach ($databaseEntries as $databaseEntry) {
|
||||
$identifierData = [];
|
||||
foreach ($this->identifiers as $identifier) {
|
||||
$identifierData[$identifier] = $databaseEntry->{$identifier};
|
||||
}
|
||||
$hash = $this->getHash($identifierData);
|
||||
$this->mapIdentifierToKey[$hash] = $databaseEntry->getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getCreatedCount(): int
|
||||
{
|
||||
return $this->createdCount;
|
||||
}
|
||||
}
|
||||
30
app/Service/Import/ImportService.php
Normal file
30
app/Service/Import/ImportService.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\Import\Importers\ImporterContract;
|
||||
use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportService
|
||||
{
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
public function import(Organization $organization, string $importerType, string $data): ReportDto
|
||||
{
|
||||
/** @var ImporterContract $importer */
|
||||
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
||||
$importer->init($organization);
|
||||
DB::transaction(function () use (&$importer, &$data) {
|
||||
$importer->importData($data);
|
||||
});
|
||||
|
||||
return $importer->getReport();
|
||||
}
|
||||
}
|
||||
87
app/Service/Import/Importers/ClockifyProjectsImporter.php
Normal file
87
app/Service/Import/Importers/ClockifyProjectsImporter.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use Exception;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
|
||||
class ClockifyProjectsImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[\Override]
|
||||
public function importData(string $data): void
|
||||
{
|
||||
try {
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$clientId = null;
|
||||
if ($record['Client'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKey([
|
||||
'name' => $record['Client'],
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
$projectId = null;
|
||||
if ($record['Name'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $record['Name'],
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($record['Tasks'] !== '') {
|
||||
$tasks = explode(', ', $record['Tasks']);
|
||||
foreach ($tasks as $task) {
|
||||
$this->taskImportHelper->getKey([
|
||||
'name' => $task,
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (CsvException $exception) {
|
||||
throw new ImportException('Invalid CSV data');
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $header
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function validateHeader(array $header): void
|
||||
{
|
||||
$requiredFields = [
|
||||
'Name',
|
||||
'Client',
|
||||
'Status',
|
||||
'Visibility',
|
||||
'Billability',
|
||||
'Tasks',
|
||||
];
|
||||
foreach ($requiredFields as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
160
app/Service/Import/Importers/ClockifyTimeEntriesImporter.php
Normal file
160
app/Service/Import/Importers/ClockifyTimeEntriesImporter.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Models\TimeEntry;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
|
||||
class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @return array<string>
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function getTags(string $tags): array
|
||||
{
|
||||
if (trim($tags) === '') {
|
||||
return [];
|
||||
}
|
||||
$tagsParsed = explode(', ', $tags);
|
||||
$tagIds = [];
|
||||
foreach ($tagsParsed as $tagParsed) {
|
||||
$tagId = $this->tagImportHelper->getKey([
|
||||
'name' => $tagParsed,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
$tagIds[] = $tagId;
|
||||
}
|
||||
|
||||
return $tagIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[\Override]
|
||||
public function importData(string $data): void
|
||||
{
|
||||
try {
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$userId = $this->userImportHelper->getKey([
|
||||
'email' => $record['Email'],
|
||||
], [
|
||||
'name' => $record['User'],
|
||||
'is_placeholder' => true,
|
||||
]);
|
||||
$clientId = null;
|
||||
if ($record['Client'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKey([
|
||||
'name' => $record['Client'],
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
$projectId = null;
|
||||
if ($record['Project'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $record['Project'],
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
]);
|
||||
}
|
||||
$taskId = null;
|
||||
if ($record['Task'] !== '') {
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record['Task'],
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
$timeEntry = new TimeEntry();
|
||||
$timeEntry->user_id = $userId;
|
||||
$timeEntry->task_id = $taskId;
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($record['Description']) > 500) {
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $record['Description'];
|
||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||
|
||||
// Start
|
||||
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) {
|
||||
$start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], 'UTC');
|
||||
} else {
|
||||
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], 'UTC');
|
||||
}
|
||||
if ($start === false) {
|
||||
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
|
||||
}
|
||||
$timeEntry->start = $start;
|
||||
|
||||
// End
|
||||
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) {
|
||||
$end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], 'UTC');
|
||||
} else {
|
||||
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], 'UTC');
|
||||
}
|
||||
if ($end === false) {
|
||||
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
|
||||
}
|
||||
$timeEntry->end = $end;
|
||||
$timeEntry->save();
|
||||
$this->timeEntriesCreated++;
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (CsvException $exception) {
|
||||
throw new ImportException('Invalid CSV data');
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $header
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function validateHeader(array $header): void
|
||||
{
|
||||
$requiredFields = [
|
||||
'Project',
|
||||
'Client',
|
||||
'Description',
|
||||
'Task',
|
||||
'User',
|
||||
'Group',
|
||||
'Email',
|
||||
'Tags',
|
||||
'Billable',
|
||||
'Start Date',
|
||||
'Start Time',
|
||||
'End Date',
|
||||
'End Time',
|
||||
];
|
||||
foreach ($requiredFields as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/Service/Import/Importers/DefaultImporter.php
Normal file
114
app/Service/Import/Importers/DefaultImporter.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Service\ColorService;
|
||||
use App\Service\Import\ImportDatabaseHelper;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
abstract class DefaultImporter implements ImporterContract
|
||||
{
|
||||
protected Organization $organization;
|
||||
|
||||
/**
|
||||
* @var ImportDatabaseHelper<User>
|
||||
*/
|
||||
protected ImportDatabaseHelper $userImportHelper;
|
||||
|
||||
/**
|
||||
* @var ImportDatabaseHelper<Project>
|
||||
*/
|
||||
protected ImportDatabaseHelper $projectImportHelper;
|
||||
|
||||
/**
|
||||
* @var ImportDatabaseHelper<Tag>
|
||||
*/
|
||||
protected ImportDatabaseHelper $tagImportHelper;
|
||||
|
||||
/**
|
||||
* @var ImportDatabaseHelper<Client>
|
||||
*/
|
||||
protected ImportDatabaseHelper $clientImportHelper;
|
||||
|
||||
/**
|
||||
* @var ImportDatabaseHelper<Task>
|
||||
*/
|
||||
protected ImportDatabaseHelper $taskImportHelper;
|
||||
|
||||
protected int $timeEntriesCreated;
|
||||
|
||||
protected ColorService $colorService;
|
||||
|
||||
public function init(Organization $organization): void
|
||||
{
|
||||
$this->organization = $organization;
|
||||
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
}, function (User $user) {
|
||||
$user->organizations()->attach($this->organization, [
|
||||
'role' => 'placeholder',
|
||||
]);
|
||||
}, validate: [
|
||||
'name' => [
|
||||
'required',
|
||||
'max:255',
|
||||
],
|
||||
]);
|
||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
}, validate: [
|
||||
'name' => [
|
||||
'required',
|
||||
'max:255',
|
||||
],
|
||||
]);
|
||||
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
}, validate: [
|
||||
'name' => [
|
||||
'required',
|
||||
'max:255',
|
||||
],
|
||||
]);
|
||||
$this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
}, validate: [
|
||||
'name' => [
|
||||
'required',
|
||||
'max:255',
|
||||
],
|
||||
]);
|
||||
$this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) {
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
}, validate: [
|
||||
'name' => [
|
||||
'required',
|
||||
'max:500',
|
||||
],
|
||||
]);
|
||||
$this->timeEntriesCreated = 0;
|
||||
$this->colorService = app(ColorService::class);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getReport(): ReportDto
|
||||
{
|
||||
return new ReportDto(
|
||||
clientsCreated: $this->clientImportHelper->getCreatedCount(),
|
||||
projectsCreated: $this->projectImportHelper->getCreatedCount(),
|
||||
tasksCreated: $this->taskImportHelper->getCreatedCount(),
|
||||
timeEntriesCreated: $this->timeEntriesCreated,
|
||||
tagsCreated: $this->tagImportHelper->getCreatedCount(),
|
||||
usersCreated: $this->userImportHelper->getCreatedCount(),
|
||||
);
|
||||
}
|
||||
}
|
||||
9
app/Service/Import/Importers/ImportException.php
Normal file
9
app/Service/Import/Importers/ImportException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
class ImportException extends \Exception
|
||||
{
|
||||
}
|
||||
16
app/Service/Import/Importers/ImporterContract.php
Normal file
16
app/Service/Import/Importers/ImporterContract.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Models\Organization;
|
||||
|
||||
interface ImporterContract
|
||||
{
|
||||
public function init(Organization $organization): void;
|
||||
|
||||
public function importData(string $data): void;
|
||||
|
||||
public function getReport(): ReportDto;
|
||||
}
|
||||
43
app/Service/Import/Importers/ImporterProvider.php
Normal file
43
app/Service/Import/Importers/ImporterProvider.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
class ImporterProvider
|
||||
{
|
||||
/**
|
||||
* @var array<string, class-string<ImporterContract>>
|
||||
*/
|
||||
private array $importers = [
|
||||
'toggl_time_entries' => TogglTimeEntriesImporter::class,
|
||||
'toggl_data_importer' => TogglDataImporter::class,
|
||||
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
|
||||
'clockify_projects' => ClockifyProjectsImporter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param class-string<ImporterContract> $importer
|
||||
*/
|
||||
public function registerImporter(string $type, string $importer): void
|
||||
{
|
||||
$this->importers[$type] = $importer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getImporterKeys(): array
|
||||
{
|
||||
return array_keys($this->importers);
|
||||
}
|
||||
|
||||
public function getImporter(string $type): ImporterContract
|
||||
{
|
||||
if (! array_key_exists($type, $this->importers)) {
|
||||
throw new \InvalidArgumentException('Invalid importer type');
|
||||
}
|
||||
|
||||
return new $this->importers[$type];
|
||||
}
|
||||
}
|
||||
76
app/Service/Import/Importers/ReportDto.php
Normal file
76
app/Service/Import/Importers/ReportDto.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
class ReportDto
|
||||
{
|
||||
public int $clientsCreated;
|
||||
|
||||
public int $projectsCreated;
|
||||
|
||||
public int $tasksCreated;
|
||||
|
||||
public int $timeEntriesCreated;
|
||||
|
||||
public int $tagsCreated;
|
||||
|
||||
public int $usersCreated;
|
||||
|
||||
public function __construct(int $clientsCreated, int $projectsCreated, int $tasksCreated, int $timeEntriesCreated, int $tagsCreated, int $usersCreated)
|
||||
{
|
||||
$this->clientsCreated = $clientsCreated;
|
||||
$this->projectsCreated = $projectsCreated;
|
||||
$this->tasksCreated = $tasksCreated;
|
||||
$this->timeEntriesCreated = $timeEntriesCreated;
|
||||
$this->tagsCreated = $tagsCreated;
|
||||
$this->usersCreated = $usersCreated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* clients: array{
|
||||
* created: int,
|
||||
* },
|
||||
* projects: array{
|
||||
* created: int,
|
||||
* },
|
||||
* tasks: array{
|
||||
* created: int,
|
||||
* },
|
||||
* time-entries: array{
|
||||
* created: int,
|
||||
* },
|
||||
* tags: array{
|
||||
* created: int,
|
||||
* },
|
||||
* users: array{
|
||||
* created: int,
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'clients' => [
|
||||
'created' => $this->clientsCreated,
|
||||
],
|
||||
'projects' => [
|
||||
'created' => $this->projectsCreated,
|
||||
],
|
||||
'tasks' => [
|
||||
'created' => $this->tasksCreated,
|
||||
],
|
||||
'time-entries' => [
|
||||
'created' => $this->timeEntriesCreated,
|
||||
],
|
||||
'tags' => [
|
||||
'created' => $this->tagsCreated,
|
||||
],
|
||||
'users' => [
|
||||
'created' => $this->usersCreated,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
116
app/Service/Import/Importers/TogglDataImporter.php
Normal file
116
app/Service/Import/Importers/TogglDataImporter.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use Exception;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ZipArchive;
|
||||
|
||||
class TogglDataImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[\Override]
|
||||
public function importData(string $data): void
|
||||
{
|
||||
try {
|
||||
$zip = new ZipArchive();
|
||||
$temporaryDirectory = TemporaryDirectory::make();
|
||||
file_put_contents($temporaryDirectory->path('import.zip'), $data);
|
||||
$zip->open($temporaryDirectory->path('import.zip'), ZipArchive::RDONLY);
|
||||
$temporaryDirectory = TemporaryDirectory::make();
|
||||
$zip->extractTo($temporaryDirectory->path());
|
||||
$zip->close();
|
||||
$clientsFileContent = file_get_contents($temporaryDirectory->path('clients.json'));
|
||||
if ($clientsFileContent === false) {
|
||||
throw new ImportException('File clients.json missing in ZIP');
|
||||
}
|
||||
$clients = json_decode($clientsFileContent);
|
||||
$projectsFileContent = file_get_contents($temporaryDirectory->path('projects.json'));
|
||||
if ($projectsFileContent === false) {
|
||||
throw new ImportException('File projects.json missing in ZIP');
|
||||
}
|
||||
$projects = json_decode($projectsFileContent);
|
||||
$tagsFileContent = file_get_contents($temporaryDirectory->path('tags.json'));
|
||||
if ($tagsFileContent === false) {
|
||||
throw new ImportException('File tags.json missing in ZIP');
|
||||
}
|
||||
$tags = json_decode($tagsFileContent);
|
||||
$workspaceUsersFileContent = file_get_contents($temporaryDirectory->path('workspace_users.json'));
|
||||
if ($workspaceUsersFileContent === false) {
|
||||
throw new ImportException('File workspace_users.json missing in ZIP');
|
||||
}
|
||||
$workspaceUsers = json_decode($workspaceUsersFileContent);
|
||||
foreach ($clients as $client) {
|
||||
$this->clientImportHelper->getKey([
|
||||
'name' => $client->name,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [], (string) $client->id);
|
||||
}
|
||||
foreach ($tags as $tag) {
|
||||
$this->tagImportHelper->getKey([
|
||||
'name' => $tag->name,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [], (string) $tag->id);
|
||||
}
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$clientId = null;
|
||||
if ($project->client_id !== null) {
|
||||
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
|
||||
if ($clientId === null) {
|
||||
throw new Exception('Client does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->colorService->isValid($project->color)) {
|
||||
throw new ImportException('Invalid color');
|
||||
}
|
||||
|
||||
$this->projectImportHelper->getKey([
|
||||
'name' => $project->name,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'color' => $project->color,
|
||||
], (string) $project->id);
|
||||
}
|
||||
foreach ($workspaceUsers as $workspaceUser) {
|
||||
$this->userImportHelper->getKey([
|
||||
'email' => $workspaceUser->email,
|
||||
], [
|
||||
'name' => $workspaceUser->name,
|
||||
'is_placeholder' => true,
|
||||
], (string) $workspaceUser->id);
|
||||
}
|
||||
$projectIds = $this->projectImportHelper->getExternalIds();
|
||||
foreach ($projectIds as $projectIdExternal) {
|
||||
$tasksFileContent = file_get_contents($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'));
|
||||
if ($tasksFileContent === false) {
|
||||
throw new ImportException('File tasks/'.$projectIdExternal.'.json missing in ZIP');
|
||||
}
|
||||
$tasks = json_decode($tasksFileContent);
|
||||
foreach ($tasks as $task) {
|
||||
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier((string) $projectIdExternal);
|
||||
|
||||
if ($projectId === null) {
|
||||
throw new Exception('Project does not exist');
|
||||
}
|
||||
$this->taskImportHelper->getKey([
|
||||
'name' => $task->name,
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [], (string) $task->id);
|
||||
}
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
144
app/Service/Import/Importers/TogglTimeEntriesImporter.php
Normal file
144
app/Service/Import/Importers/TogglTimeEntriesImporter.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Models\TimeEntry;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
|
||||
class TogglTimeEntriesImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @return array<string>
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function getTags(string $tags): array
|
||||
{
|
||||
if (trim($tags) === '') {
|
||||
return [];
|
||||
}
|
||||
$tagsParsed = explode(', ', $tags);
|
||||
$tagIds = [];
|
||||
foreach ($tagsParsed as $tagParsed) {
|
||||
$tagId = $this->tagImportHelper->getKey([
|
||||
'name' => $tagParsed,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
$tagIds[] = $tagId;
|
||||
}
|
||||
|
||||
return $tagIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[\Override]
|
||||
public function importData(string $data): void
|
||||
{
|
||||
try {
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$userId = $this->userImportHelper->getKey([
|
||||
'email' => $record['Email'],
|
||||
], [
|
||||
'name' => $record['User'],
|
||||
'is_placeholder' => true,
|
||||
]);
|
||||
$clientId = null;
|
||||
if ($record['Client'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKey([
|
||||
'name' => $record['Client'],
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
$projectId = null;
|
||||
if ($record['Project'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $record['Project'],
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
]);
|
||||
}
|
||||
$taskId = null;
|
||||
if ($record['Task'] !== '') {
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record['Task'],
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
$timeEntry = new TimeEntry();
|
||||
$timeEntry->user_id = $userId;
|
||||
$timeEntry->task_id = $taskId;
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
$timeEntry->description = $record['Description'];
|
||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||
$start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], 'UTC');
|
||||
if ($start === false) {
|
||||
throw new ImportException('Start date ("'.$record['Start date'].'") or time ("'.$record['Start time'].'") are invalid');
|
||||
}
|
||||
$timeEntry->start = $start;
|
||||
$end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], 'UTC');
|
||||
if ($end === false) {
|
||||
throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid');
|
||||
}
|
||||
$timeEntry->end = $end;
|
||||
$timeEntry->save();
|
||||
$this->timeEntriesCreated++;
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (CsvException $exception) {
|
||||
throw new ImportException('Invalid CSV data');
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $header
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function validateHeader(array $header): void
|
||||
{
|
||||
$requiredFields = [
|
||||
'User',
|
||||
'Email',
|
||||
'Client',
|
||||
'Project',
|
||||
'Task',
|
||||
'Description',
|
||||
'Billable',
|
||||
'Start date',
|
||||
'Start time',
|
||||
'End date',
|
||||
'End time',
|
||||
'Tags',
|
||||
];
|
||||
foreach ($requiredFields as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/Service/UserService.php
Normal file
23
app/Service/UserService.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
|
||||
class UserService
|
||||
{
|
||||
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
|
||||
{
|
||||
// Time entries
|
||||
TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereBelongsTo($fromUser, 'user')
|
||||
->update([
|
||||
'user_id' => $toUser->getKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"require": {
|
||||
"php": "8.3.*",
|
||||
"ext-zip": "*",
|
||||
"dedoc/scramble": "^0.8.5",
|
||||
"filament/filament": "^3.2",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
@@ -16,6 +17,7 @@
|
||||
"laravel/passport": "^11.10.2",
|
||||
"laravel/tinker": "^2.8",
|
||||
"pxlrbt/filament-environment-indicator": "^2.0",
|
||||
"spatie/temporary-directory": "^2.2",
|
||||
"tightenco/ziggy": "^1.0",
|
||||
"tpetry/laravel-postgresql-enhanced": "^0.33.0"
|
||||
},
|
||||
|
||||
66
composer.lock
generated
66
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "e83929e68d256367652d91e43a79288e",
|
||||
"content-hash": "9e9c41ae5787e1aa711b04cc019cb7e7",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -6542,6 +6542,67 @@
|
||||
],
|
||||
"time": "2024-01-11T08:43:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/temporary-directory",
|
||||
"version": "2.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/temporary-directory.git",
|
||||
"reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/temporary-directory/zipball/76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a",
|
||||
"reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\TemporaryDirectory\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alex Vanderbist",
|
||||
"email": "alex@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Easily create, use and destroy temporary directories",
|
||||
"homepage": "https://github.com/spatie/temporary-directory",
|
||||
"keywords": [
|
||||
"php",
|
||||
"spatie",
|
||||
"temporary-directory"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/temporary-directory/issues",
|
||||
"source": "https://github.com/spatie/temporary-directory/tree/2.2.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-12-25T11:46:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v6.4.4",
|
||||
@@ -12424,7 +12485,8 @@
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "8.3.*"
|
||||
"php": "8.3.*",
|
||||
"ext-zip": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.6.0"
|
||||
|
||||
@@ -80,6 +80,21 @@ return [
|
||||
'sslmode' => 'prefer',
|
||||
],
|
||||
|
||||
'pgsql_test' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DATABASE_URL'),
|
||||
'host' => env('DB_TEST_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_TEST_PORT', '5432'),
|
||||
'database' => env('DB_TEST_DATABASE', 'forge'),
|
||||
'username' => env('DB_TEST_USERNAME', 'forge'),
|
||||
'password' => env('DB_TEST_PASSWORD', ''),
|
||||
'charset' => 'utf8',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => 'prefer',
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DATABASE_URL'),
|
||||
|
||||
@@ -58,6 +58,12 @@ return [
|
||||
'throw' => false,
|
||||
],
|
||||
|
||||
'testfiles' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('tests'),
|
||||
'throw' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -64,9 +64,9 @@ return [
|
||||
* ```
|
||||
*/
|
||||
'servers' => [
|
||||
'Production' => 'https://app.solidtime.io',
|
||||
'Staging' => 'https://app.staging.solidtime.io',
|
||||
'Local' => 'https://soldtime.test',
|
||||
'Production' => 'https://app.solidtime.io/api',
|
||||
'Staging' => 'https://app.staging.solidtime.io/api',
|
||||
'Local' => 'https://soldtime.test/api',
|
||||
],
|
||||
|
||||
'middleware' => [
|
||||
|
||||
@@ -98,7 +98,6 @@ return [
|
||||
],
|
||||
|
||||
'ignore_paths' => [
|
||||
'livewire*',
|
||||
'nova-api*',
|
||||
'pulse*',
|
||||
],
|
||||
@@ -156,7 +155,7 @@ return [
|
||||
|
||||
Watchers\LogWatcher::class => [
|
||||
'enabled' => env('TELESCOPE_LOG_WATCHER', true),
|
||||
'level' => 'error',
|
||||
'level' => 'debug',
|
||||
],
|
||||
|
||||
Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
|
||||
|
||||
@@ -27,10 +27,10 @@ class OrganizationFactory extends Factory
|
||||
];
|
||||
}
|
||||
|
||||
public function withOwner(): self
|
||||
public function withOwner(?User $owner = null): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'user_id' => User::factory(),
|
||||
'user_id' => $owner === null ? User::factory() : $owner,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Database\Factories;
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Service\ColorService;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,7 @@ class ProjectFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->company(),
|
||||
'color' => $this->faker->hexColor(),
|
||||
'color' => app(ColorService::class)->getRandomColor(),
|
||||
'organization_id' => Organization::factory(),
|
||||
'client_id' => null,
|
||||
];
|
||||
|
||||
@@ -31,9 +31,19 @@ class UserFactory extends Factory
|
||||
'remember_token' => Str::random(10),
|
||||
'profile_photo_path' => null,
|
||||
'current_team_id' => null,
|
||||
'is_placeholder' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function placeholder(bool $placeholder = true): static
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($placeholder): array {
|
||||
return [
|
||||
'is_placeholder' => $placeholder,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
|
||||
@@ -16,13 +16,17 @@ return new class extends Migration
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->string('email');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->string('password')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->boolean('is_placeholder')->default(false);
|
||||
$table->foreignUuid('current_team_id')->nullable();
|
||||
$table->string('profile_photo_path', 2048)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->uniqueIndex('email')
|
||||
->where('is_placeholder = false');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('tasks', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name', 255);
|
||||
$table->string('name', 500);
|
||||
$table->uuid('project_id');
|
||||
$table->foreign('project_id')
|
||||
->references('id')
|
||||
|
||||
@@ -15,7 +15,7 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('time_entries', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('description', 255);
|
||||
$table->string('description', 500);
|
||||
$table->dateTime('start');
|
||||
$table->dateTime('end')->nullable();
|
||||
$table->boolean('billable')->default(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace Database\Seeders;
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
@@ -22,31 +23,57 @@ class DatabaseSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
$this->deleteAll();
|
||||
$organization1 = Organization::factory()->create([
|
||||
$userAcmeOwner = User::factory()->create([
|
||||
'name' => 'ACME Admin',
|
||||
'email' => 'owner@acme.test',
|
||||
]);
|
||||
$organizationAcme = Organization::factory()->withOwner($userAcmeOwner)->create([
|
||||
'name' => 'ACME Corp',
|
||||
]);
|
||||
$user1 = User::factory()->withPersonalOrganization()->create([
|
||||
$userAcmeManager = User::factory()->withPersonalOrganization()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
$employee1 = User::factory()->withPersonalOrganization()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'employee@example.com',
|
||||
]);
|
||||
$userAcmeAdmin = User::factory()->create([
|
||||
$userAcmeAdmin = User::factory()->withPersonalOrganization()->create([
|
||||
'name' => 'ACME Admin',
|
||||
'email' => 'admin@acme.test',
|
||||
]);
|
||||
$user1->organizations()->attach($organization1, [
|
||||
$userAcmeEmployee = User::factory()->withPersonalOrganization()->create([
|
||||
'name' => 'Max Mustermann',
|
||||
'email' => 'max.mustermann@acme.test',
|
||||
]);
|
||||
$userAcmePlaceholder = User::factory()->placeholder()->create([
|
||||
'name' => 'Old Employee',
|
||||
'email' => 'old.employee@acme.test',
|
||||
'password' => null,
|
||||
]);
|
||||
$userAcmeManager->organizations()->attach($organizationAcme, [
|
||||
'role' => 'manager',
|
||||
]);
|
||||
$userAcmeAdmin->organizations()->attach($organization1, [
|
||||
$userAcmeAdmin->organizations()->attach($organizationAcme, [
|
||||
'role' => 'admin',
|
||||
]);
|
||||
$timeEntriesEmployees = TimeEntry::factory()
|
||||
$userAcmeEmployee->organizations()->attach($organizationAcme, [
|
||||
'role' => 'employee',
|
||||
]);
|
||||
$userAcmePlaceholder->organizations()->attach($organizationAcme, [
|
||||
'role' => 'employee',
|
||||
]);
|
||||
|
||||
$timeEntriesAcmeAdmin = TimeEntry::factory()
|
||||
->count(10)
|
||||
->forUser($employee1)
|
||||
->forOrganization($organization1)
|
||||
->forUser($userAcmeAdmin)
|
||||
->forOrganization($organizationAcme)
|
||||
->create();
|
||||
$timeEntriesAcmePlaceholder = TimeEntry::factory()
|
||||
->count(10)
|
||||
->forUser($userAcmePlaceholder)
|
||||
->forOrganization($organizationAcme)
|
||||
->create();
|
||||
$timeEntriesAcmePlaceholder = TimeEntry::factory()
|
||||
->count(10)
|
||||
->forUser($userAcmeEmployee)
|
||||
->forOrganization($organizationAcme)
|
||||
->create();
|
||||
$client = Client::factory()->create([
|
||||
'name' => 'Big Company',
|
||||
@@ -63,11 +90,11 @@ class DatabaseSeeder extends Seeder
|
||||
$organization2 = Organization::factory()->create([
|
||||
'name' => 'Rival Corp',
|
||||
]);
|
||||
$user1 = User::factory()->withPersonalOrganization()->create([
|
||||
$userAcmeManager = User::factory()->withPersonalOrganization()->create([
|
||||
'name' => 'Other User',
|
||||
'email' => 'test@rival-company.test',
|
||||
]);
|
||||
$user1->organizations()->attach($organization2, [
|
||||
$userAcmeManager->organizations()->attach($organization2, [
|
||||
'role' => 'admin',
|
||||
]);
|
||||
$otherCompanyProject = Project::factory()->forClient($client)->create([
|
||||
@@ -83,6 +110,7 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
DB::table((new TimeEntry())->getTable())->delete();
|
||||
DB::table((new Task())->getTable())->delete();
|
||||
DB::table((new Tag())->getTable())->delete();
|
||||
DB::table((new Project())->getTable())->delete();
|
||||
DB::table((new Client())->getTable())->delete();
|
||||
DB::table((new User())->getTable())->delete();
|
||||
|
||||
@@ -57,14 +57,49 @@ services:
|
||||
- '${DB_USERNAME}'
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
mailpit:
|
||||
image: 'axllent/mailpit:latest'
|
||||
pgsql_test:
|
||||
image: 'postgres:15'
|
||||
environment:
|
||||
PGPASSWORD: '${DB_PASSWORD:-secret}'
|
||||
POSTGRES_DB: '${DB_DATABASE}'
|
||||
POSTGRES_USER: '${DB_USERNAME}'
|
||||
POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'
|
||||
volumes:
|
||||
- 'sail-pgsql-test:/var/lib/postgresql/data'
|
||||
- './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- pg_isready
|
||||
- '-q'
|
||||
- '-d'
|
||||
- '${DB_DATABASE}'
|
||||
- '-U'
|
||||
- '${DB_USERNAME}'
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
mailpit:
|
||||
image: 'axllent/mailpit:latest'
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=${NETWORK_NAME}"
|
||||
- "traefik.http.routers.solidtime-mailpit.rule=Host(`mail.${NGINX_HOST_NAME}`)"
|
||||
- "traefik.http.routers.solidtime-mailpit.entrypoints=web"
|
||||
- "traefik.http.services.solidtime-mailpit.loadbalancer.server.port=8025"
|
||||
- "traefik.http.routers.solidtime-mailpit-https.rule=Host(`mail.${NGINX_HOST_NAME}`)"
|
||||
- "traefik.http.routers.solidtime-mailpit-https.entrypoints=websecure"
|
||||
- "traefik.http.routers.solidtime-mailpit-https.tls=true"
|
||||
networks:
|
||||
- sail
|
||||
- reverse-proxy
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||
working_dir: /src
|
||||
extra_hosts:
|
||||
- "solidtime.test:${REVERSE_PROXY_IP:-10.100.100.10}"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=${NETWORK_NAME}"
|
||||
@@ -88,3 +123,5 @@ networks:
|
||||
volumes:
|
||||
sail-pgsql:
|
||||
driver: local
|
||||
sail-pgsql-test:
|
||||
driver: local
|
||||
|
||||
@@ -8,9 +8,7 @@ async function registerNewUser(page, email, password) {
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByLabel('Confirm Password').fill(password);
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboard' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||
}
|
||||
|
||||
test('can register, logout and log back in', async ({ page }) => {
|
||||
@@ -18,20 +16,15 @@ test('can register, logout and log back in', async ({ page }) => {
|
||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||
const password = 'suchagreatpassword123';
|
||||
await registerNewUser(page, email, password);
|
||||
await expect(
|
||||
page.getByRole('button', { name: "John's Organization" })
|
||||
).toBeVisible();
|
||||
await page.locator('#currentUserButton').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Log Out' }).click();
|
||||
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||
await page.getByTestId('current_user_button').click();
|
||||
await page.getByText('Log Out').click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/');
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboard' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can register and delete account', async ({ page }) => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
|
||||
async function goToOrganizationSettings(page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await page.locator('#currentTeamButton').click();
|
||||
await page.getByRole('link', { name: 'Team Settings' }).click();
|
||||
await page.getByTestId('organization_switcher').click();
|
||||
await page.getByText('Team Settings').click();
|
||||
}
|
||||
|
||||
test('test that organization name can be updated', async ({ page }) => {
|
||||
@@ -12,14 +12,28 @@ test('test that organization name can be updated', async ({ page }) => {
|
||||
await page.getByLabel('Team Name').fill('NEW ORG NAME');
|
||||
await page.getByLabel('Team Name').press('Enter');
|
||||
await page.getByLabel('Team Name').press('Meta+r');
|
||||
await expect(page.getByRole('navigation')).toContainText('NEW ORG NAME');
|
||||
await expect(page.getByTestId('organization_switcher')).toContainText(
|
||||
'NEW ORG NAME'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that new editor can be invited', async ({ page }) => {
|
||||
test('test that new manager can be invited', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
const editorId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
||||
await page.getByRole('button', { name: 'Editor' }).click();
|
||||
await page.getByRole('button', { name: 'Manager' }).click();
|
||||
await page.getByRole('button', { name: 'Add' }).click();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
);
|
||||
});
|
||||
|
||||
test('test that new employee can be invited', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
const editorId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
||||
await page.getByRole('button', { name: 'Employee' }).click();
|
||||
await page.getByRole('button', { name: 'Add' }).click();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('main')).toContainText(
|
||||
|
||||
435
e2e/timetracker.spec.ts
Normal file
435
e2e/timetracker.spec.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import { expect, test } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
|
||||
async function goToDashboard(page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
}
|
||||
|
||||
async function startOrStopTimerWithButton(page) {
|
||||
await page
|
||||
.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
||||
.click();
|
||||
}
|
||||
|
||||
async function assertThatTimerHasStarted(page) {
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-red-400/80'
|
||||
);
|
||||
}
|
||||
|
||||
async function assertNewTimeEntryResponse(page) {
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function assertThatTimerIsStoped(page) {
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
}
|
||||
|
||||
test('test that starting and stopping a timer without description and project works', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await assertNewTimeEntryResponse(page);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(1500);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await assertThatTimerIsStoped(page);
|
||||
});
|
||||
|
||||
test('test that starting and stopping a timer with a description works', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await page
|
||||
.getByTestId('time_entry_description')
|
||||
.fill('New Time Entry Description');
|
||||
await startOrStopTimerWithButton(page);
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description ===
|
||||
'New Time Entry Description' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(500);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description ===
|
||||
'New Time Entry Description' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await assertThatTimerIsStoped(page);
|
||||
});
|
||||
|
||||
test('test that starting and updating the description while running works', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(500);
|
||||
await page
|
||||
.getByTestId('time_entry_description')
|
||||
.fill('New Time Entry Description');
|
||||
await page.getByTestId('time_entry_description').press('Tab');
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description ===
|
||||
'New Time Entry Description' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description ===
|
||||
'New Time Entry Description' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await assertThatTimerIsStoped(page);
|
||||
});
|
||||
|
||||
test('test that starting and updating the time while running works', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await startOrStopTimerWithButton(page);
|
||||
const createResponse = await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByTestId('time_entry_time').fill('20min');
|
||||
await page.getByTestId('time_entry_time').press('Tab');
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.start !==
|
||||
(await createResponse.json()).data.start &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
|
||||
await page.waitForTimeout(500);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await assertThatTimerIsStoped(page);
|
||||
});
|
||||
|
||||
test('test that entering a time starts the timer on blur', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('20min');
|
||||
await page.getByTestId('time_entry_time').press('Tab');
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await assertThatTimerHasStarted(page);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that entering a time starts the timer on enter', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('20min');
|
||||
await page.getByTestId('time_entry_time').press('Enter');
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await assertThatTimerHasStarted(page);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
});
|
||||
await assertThatTimerIsStoped(page);
|
||||
});
|
||||
|
||||
test('test that adding a new tag works', async ({ page }) => {
|
||||
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('tag_dropdown').click();
|
||||
await page.getByTestId('tag_dropdown_search').fill(newTagName);
|
||||
await page.getByTestId('tag_dropdown_search').press('Enter');
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.name === newTagName
|
||||
);
|
||||
});
|
||||
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue('');
|
||||
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that adding a new tag when the timer is running', async ({
|
||||
page,
|
||||
}) => {
|
||||
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
|
||||
await goToDashboard(page);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await assertNewTimeEntryResponse(page);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.getByTestId('tag_dropdown').click();
|
||||
await page.getByTestId('tag_dropdown_search').fill(newTagName);
|
||||
await page.getByTestId('tag_dropdown_search').press('Enter');
|
||||
const tagCreateResponse = await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.name === newTagName
|
||||
);
|
||||
});
|
||||
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue('');
|
||||
|
||||
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
|
||||
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([(await tagCreateResponse.json()).data.id])
|
||||
);
|
||||
});
|
||||
await page.getByTestId('tag_dropdown_search').press('Escape');
|
||||
await page.waitForTimeout(1000);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([(await tagCreateResponse.json()).data.id])
|
||||
);
|
||||
});
|
||||
await assertThatTimerIsStoped(page);
|
||||
});
|
||||
|
||||
// test that adding a new tag when the timer is running
|
||||
|
||||
// test that search is working
|
||||
22
lang/en/auth.php
Normal file
22
lang/en/auth.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used during authentication for various
|
||||
| messages that we need to display to the user. You are free to modify
|
||||
| these language lines according to your application's requirements.
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => 'These credentials do not match our records.',
|
||||
'password' => 'The provided password is incorrect.',
|
||||
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||
|
||||
];
|
||||
13
lang/en/exceptions.php
Normal file
13
lang/en/exceptions.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
|
||||
return [
|
||||
'api' => [
|
||||
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
|
||||
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
|
||||
],
|
||||
];
|
||||
21
lang/en/pagination.php
Normal file
21
lang/en/pagination.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Pagination Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used by the paginator library to build
|
||||
| the simple pagination links. You are free to change them to anything
|
||||
| you want to customize your views to better match your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'previous' => '« Previous',
|
||||
'next' => 'Next »',
|
||||
|
||||
];
|
||||
24
lang/en/passwords.php
Normal file
24
lang/en/passwords.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Reset Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are the default lines which match reasons
|
||||
| that are given by the password broker for a password update attempt
|
||||
| has failed, such as for an invalid token or invalid new password.
|
||||
|
|
||||
*/
|
||||
|
||||
'reset' => 'Your password has been reset.',
|
||||
'sent' => 'We have emailed your password reset link.',
|
||||
'throttled' => 'Please wait before retrying.',
|
||||
'token' => 'This password reset token is invalid.',
|
||||
'user' => "We can't find a user with that email address.",
|
||||
|
||||
];
|
||||
199
lang/en/validation.php
Normal file
199
lang/en/validation.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Validation Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines contain the default error messages used by
|
||||
| the validator class. Some of these rules have multiple versions such
|
||||
| as the size rules. Feel free to tweak each of these messages here.
|
||||
|
|
||||
*/
|
||||
|
||||
'accepted' => 'The :attribute field must be accepted.',
|
||||
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
|
||||
'active_url' => 'The :attribute field must be a valid URL.',
|
||||
'after' => 'The :attribute field must be a date after :date.',
|
||||
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
|
||||
'alpha' => 'The :attribute field must only contain letters.',
|
||||
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
|
||||
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
|
||||
'array' => 'The :attribute field must be an array.',
|
||||
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
|
||||
'before' => 'The :attribute field must be a date before :date.',
|
||||
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
|
||||
'between' => [
|
||||
'array' => 'The :attribute field must have between :min and :max items.',
|
||||
'file' => 'The :attribute field must be between :min and :max kilobytes.',
|
||||
'numeric' => 'The :attribute field must be between :min and :max.',
|
||||
'string' => 'The :attribute field must be between :min and :max characters.',
|
||||
],
|
||||
'boolean' => 'The :attribute field must be true or false.',
|
||||
'can' => 'The :attribute field contains an unauthorized value.',
|
||||
'confirmed' => 'The :attribute field confirmation does not match.',
|
||||
'current_password' => 'The password is incorrect.',
|
||||
'date' => 'The :attribute field must be a valid date.',
|
||||
'date_equals' => 'The :attribute field must be a date equal to :date.',
|
||||
'date_format' => 'The :attribute field must match the format :format.',
|
||||
'decimal' => 'The :attribute field must have :decimal decimal places.',
|
||||
'declined' => 'The :attribute field must be declined.',
|
||||
'declined_if' => 'The :attribute field must be declined when :other is :value.',
|
||||
'different' => 'The :attribute field and :other must be different.',
|
||||
'digits' => 'The :attribute field must be :digits digits.',
|
||||
'digits_between' => 'The :attribute field must be between :min and :max digits.',
|
||||
'dimensions' => 'The :attribute field has invalid image dimensions.',
|
||||
'distinct' => 'The :attribute field has a duplicate value.',
|
||||
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
|
||||
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
|
||||
'email' => 'The :attribute field must be a valid email address.',
|
||||
'ends_with' => 'The :attribute field must end with one of the following: :values.',
|
||||
'enum' => 'The selected :attribute is invalid.',
|
||||
'exists' => 'The selected :attribute is invalid.',
|
||||
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
|
||||
'file' => 'The :attribute field must be a file.',
|
||||
'filled' => 'The :attribute field must have a value.',
|
||||
'gt' => [
|
||||
'array' => 'The :attribute field must have more than :value items.',
|
||||
'file' => 'The :attribute field must be greater than :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be greater than :value.',
|
||||
'string' => 'The :attribute field must be greater than :value characters.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'The :attribute field must have :value items or more.',
|
||||
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be greater than or equal to :value.',
|
||||
'string' => 'The :attribute field must be greater than or equal to :value characters.',
|
||||
],
|
||||
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
|
||||
'image' => 'The :attribute field must be an image.',
|
||||
'in' => 'The selected :attribute is invalid.',
|
||||
'in_array' => 'The :attribute field must exist in :other.',
|
||||
'integer' => 'The :attribute field must be an integer.',
|
||||
'ip' => 'The :attribute field must be a valid IP address.',
|
||||
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
|
||||
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
|
||||
'json' => 'The :attribute field must be a valid JSON string.',
|
||||
'lowercase' => 'The :attribute field must be lowercase.',
|
||||
'lt' => [
|
||||
'array' => 'The :attribute field must have less than :value items.',
|
||||
'file' => 'The :attribute field must be less than :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be less than :value.',
|
||||
'string' => 'The :attribute field must be less than :value characters.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'The :attribute field must not have more than :value items.',
|
||||
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be less than or equal to :value.',
|
||||
'string' => 'The :attribute field must be less than or equal to :value characters.',
|
||||
],
|
||||
'mac_address' => 'The :attribute field must be a valid MAC address.',
|
||||
'max' => [
|
||||
'array' => 'The :attribute field must not have more than :max items.',
|
||||
'file' => 'The :attribute field must not be greater than :max kilobytes.',
|
||||
'numeric' => 'The :attribute field must not be greater than :max.',
|
||||
'string' => 'The :attribute field must not be greater than :max characters.',
|
||||
],
|
||||
'max_digits' => 'The :attribute field must not have more than :max digits.',
|
||||
'mimes' => 'The :attribute field must be a file of type: :values.',
|
||||
'mimetypes' => 'The :attribute field must be a file of type: :values.',
|
||||
'min' => [
|
||||
'array' => 'The :attribute field must have at least :min items.',
|
||||
'file' => 'The :attribute field must be at least :min kilobytes.',
|
||||
'numeric' => 'The :attribute field must be at least :min.',
|
||||
'string' => 'The :attribute field must be at least :min characters.',
|
||||
],
|
||||
'min_digits' => 'The :attribute field must have at least :min digits.',
|
||||
'missing' => 'The :attribute field must be missing.',
|
||||
'missing_if' => 'The :attribute field must be missing when :other is :value.',
|
||||
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
|
||||
'missing_with' => 'The :attribute field must be missing when :values is present.',
|
||||
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
|
||||
'multiple_of' => 'The :attribute field must be a multiple of :value.',
|
||||
'not_in' => 'The selected :attribute is invalid.',
|
||||
'not_regex' => 'The :attribute field format is invalid.',
|
||||
'numeric' => 'The :attribute field must be a number.',
|
||||
'password' => [
|
||||
'letters' => 'The :attribute field must contain at least one letter.',
|
||||
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
|
||||
'numbers' => 'The :attribute field must contain at least one number.',
|
||||
'symbols' => 'The :attribute field must contain at least one symbol.',
|
||||
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
|
||||
],
|
||||
'present' => 'The :attribute field must be present.',
|
||||
'present_if' => 'The :attribute field must be present when :other is :value.',
|
||||
'present_unless' => 'The :attribute field must be present unless :other is :value.',
|
||||
'present_with' => 'The :attribute field must be present when :values is present.',
|
||||
'present_with_all' => 'The :attribute field must be present when :values are present.',
|
||||
'prohibited' => 'The :attribute field is prohibited.',
|
||||
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
|
||||
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
|
||||
'prohibits' => 'The :attribute field prohibits :other from being present.',
|
||||
'regex' => 'The :attribute field format is invalid.',
|
||||
'required' => 'The :attribute field is required.',
|
||||
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
|
||||
'required_if' => 'The :attribute field is required when :other is :value.',
|
||||
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
|
||||
'required_unless' => 'The :attribute field is required unless :other is in :values.',
|
||||
'required_with' => 'The :attribute field is required when :values is present.',
|
||||
'required_with_all' => 'The :attribute field is required when :values are present.',
|
||||
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||
'required_without_all' => 'The :attribute field is required when none of :values are present.',
|
||||
'same' => 'The :attribute field must match :other.',
|
||||
'size' => [
|
||||
'array' => 'The :attribute field must contain :size items.',
|
||||
'file' => 'The :attribute field must be :size kilobytes.',
|
||||
'numeric' => 'The :attribute field must be :size.',
|
||||
'string' => 'The :attribute field must be :size characters.',
|
||||
],
|
||||
'starts_with' => 'The :attribute field must start with one of the following: :values.',
|
||||
'string' => 'The :attribute field must be a string.',
|
||||
'timezone' => 'The :attribute field must be a valid timezone.',
|
||||
'unique' => 'The :attribute has already been taken.',
|
||||
'uploaded' => 'The :attribute failed to upload.',
|
||||
'uppercase' => 'The :attribute field must be uppercase.',
|
||||
'url' => 'The :attribute field must be a valid URL.',
|
||||
'ulid' => 'The :attribute field must be a valid ULID.',
|
||||
'uuid' => 'The :attribute field must be a valid UUID.',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Custom Validation Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify custom validation messages for attributes using the
|
||||
| convention "attribute.rule" to name the lines. This makes it quick to
|
||||
| specify a specific custom language line for a given attribute rule.
|
||||
|
|
||||
*/
|
||||
|
||||
'custom' => [
|
||||
'attribute-name' => [
|
||||
'rule-name' => 'custom-message',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Custom Validation Attributes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used to swap our attribute placeholder
|
||||
| with something more reader friendly such as "E-Mail Address" instead
|
||||
| of "email". This simply helps us make our message more expressive.
|
||||
|
|
||||
*/
|
||||
|
||||
'attributes' => [],
|
||||
|
||||
/*
|
||||
* Custom validation rules
|
||||
*/
|
||||
|
||||
'color' => 'The :attribute field must be a valid color.',
|
||||
|
||||
];
|
||||
1
openapi.json
Normal file
1
openapi.json
Normal file
File diff suppressed because one or more lines are too long
991
openapi.json.client.ts
Normal file
991
openapi.json.client.ts
Normal file
@@ -0,0 +1,991 @@
|
||||
import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ClientResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
const ClientCollection = z.array(ClientResource);
|
||||
const v1_import_import_Body = z
|
||||
.object({ type: z.string(), data: z.string() })
|
||||
.passthrough();
|
||||
const OrganizationResource = z
|
||||
.object({ id: z.string(), name: z.string(), is_personal: z.string() })
|
||||
.passthrough();
|
||||
const ProjectResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
client_id: z.union([z.string(), z.null()]),
|
||||
})
|
||||
.passthrough();
|
||||
const ProjectCollection = z.array(ProjectResource);
|
||||
const createProject_Body = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
client_id: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const TagResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
const TagCollection = z.array(TagResource);
|
||||
const before = z.union([z.string(), z.null()]).optional();
|
||||
const TimeEntryResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
start: z.string(),
|
||||
end: z.union([z.string(), z.null()]),
|
||||
duration: z.union([z.number(), z.null()]),
|
||||
description: z.union([z.string(), z.null()]),
|
||||
task_id: z.union([z.string(), z.null()]),
|
||||
project_id: z.union([z.string(), z.null()]),
|
||||
user_id: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
})
|
||||
.passthrough();
|
||||
const TimeEntryCollection = z.array(TimeEntryResource);
|
||||
const createTimeEntry_Body = z
|
||||
.object({
|
||||
user_id: z.string().uuid(),
|
||||
task_id: z.union([z.string(), z.null()]).optional(),
|
||||
start: z.string(),
|
||||
end: z.union([z.string(), z.null()]).optional(),
|
||||
description: z.union([z.string(), z.null()]).optional(),
|
||||
tags: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const updateTimeEntry_Body = z
|
||||
.object({
|
||||
task_id: z.union([z.string(), z.null()]).optional(),
|
||||
start: z.string(),
|
||||
end: z.union([z.string(), z.null()]).optional(),
|
||||
description: z.union([z.string(), z.null()]).optional(),
|
||||
tags: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const UserResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
role: z.string(),
|
||||
is_placeholder: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
const UserCollection = z.array(UserResource);
|
||||
|
||||
export const schemas = {
|
||||
ClientResource,
|
||||
ClientCollection,
|
||||
v1_import_import_Body,
|
||||
OrganizationResource,
|
||||
ProjectResource,
|
||||
ProjectCollection,
|
||||
createProject_Body,
|
||||
TagResource,
|
||||
TagCollection,
|
||||
before,
|
||||
TimeEntryResource,
|
||||
TimeEntryCollection,
|
||||
createTimeEntry_Body,
|
||||
updateTimeEntry_Body,
|
||||
UserResource,
|
||||
UserCollection,
|
||||
};
|
||||
|
||||
const endpoints = makeApi([
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization',
|
||||
alias: 'v1.organizations.show',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: OrganizationResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'put',
|
||||
path: '/v1/organizations/:organization',
|
||||
alias: 'v1.organizations.update',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({ name: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: OrganizationResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/clients',
|
||||
alias: 'v1.clients.index',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: ClientCollection }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/clients',
|
||||
alias: 'v1.clients.store',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({ name: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: ClientResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'put',
|
||||
path: '/v1/organizations/:organization/clients/:client',
|
||||
alias: 'v1.clients.update',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({ name: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'client',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: ClientResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/organizations/:organization/clients/:client',
|
||||
alias: 'v1.clients.destroy',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'client',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/import',
|
||||
alias: 'v1.import.import',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: v1_import_import_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z
|
||||
.object({
|
||||
report: z
|
||||
.object({
|
||||
clients: z
|
||||
.object({ created: z.number().int() })
|
||||
.passthrough(),
|
||||
projects: z
|
||||
.object({ created: z.number().int() })
|
||||
.passthrough(),
|
||||
tasks: z
|
||||
.object({ created: z.number().int() })
|
||||
.passthrough(),
|
||||
'time-entries': z
|
||||
.object({ created: z.number().int() })
|
||||
.passthrough(),
|
||||
tags: z
|
||||
.object({ created: z.number().int() })
|
||||
.passthrough(),
|
||||
users: z
|
||||
.object({ created: z.number().int() })
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/projects',
|
||||
alias: 'getProjects',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: ProjectCollection }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/projects',
|
||||
alias: 'createProject',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: createProject_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: ProjectResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/projects/:project',
|
||||
alias: 'getProject',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'project',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: ProjectResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'put',
|
||||
path: '/v1/organizations/:organization/projects/:project',
|
||||
alias: 'updateProject',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: createProject_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'project',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: ProjectResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/organizations/:organization/projects/:project',
|
||||
alias: 'deleteProject',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'project',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/tags',
|
||||
alias: 'getTags',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: TagCollection }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/tags',
|
||||
alias: 'createTag',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({ name: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: TagResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'put',
|
||||
path: '/v1/organizations/:organization/tags/:tag',
|
||||
alias: 'updateTag',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({ name: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'tag',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: TagResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/organizations/:organization/tags/:tag',
|
||||
alias: 'deleteTag',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'tag',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/time-entries',
|
||||
alias: 'getTimeEntries',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
type: 'Query',
|
||||
schema: z.string().uuid().optional(),
|
||||
},
|
||||
{
|
||||
name: 'before',
|
||||
type: 'Query',
|
||||
schema: before,
|
||||
},
|
||||
{
|
||||
name: 'after',
|
||||
type: 'Query',
|
||||
schema: before,
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'Query',
|
||||
schema: z.number().int().gte(1).lte(500).optional(),
|
||||
},
|
||||
{
|
||||
name: 'only_full_dates',
|
||||
type: 'Query',
|
||||
schema: z.boolean().optional(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: TimeEntryCollection }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/time-entries',
|
||||
alias: 'createTimeEntry',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: createTimeEntry_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: TimeEntryResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'put',
|
||||
path: '/v1/organizations/:organization/time-entries/:timeEntry',
|
||||
alias: 'updateTimeEntry',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: updateTimeEntry_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'timeEntry',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: TimeEntryResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/organizations/:organization/time-entries/:timeEntry',
|
||||
alias: 'deleteTimeEntry',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'timeEntry',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/users',
|
||||
alias: 'v1.users.index',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: UserCollection }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/users/:user/invite-placeholder',
|
||||
alias: 'v1.users.invite-placeholder',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.string(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export const api = new Zodios('/api', endpoints);
|
||||
|
||||
export function createApiClient(baseUrl: string, options?: ZodiosOptions) {
|
||||
return new Zodios(baseUrl, endpoints, options);
|
||||
}
|
||||
2411
package-lock.json
generated
2411
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -7,7 +7,8 @@
|
||||
"lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore .",
|
||||
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test:e2e": "npx playwright test"
|
||||
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
|
||||
"generate:zod": "npx openapi-zod-client http://localhost:80/docs/api.json --output openapi.json.client.ts --base-url /api"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inertiajs/vue3": "^1.0.0",
|
||||
@@ -20,6 +21,7 @@
|
||||
"autoprefixer": "^10.4.7",
|
||||
"axios": "^1.6.4",
|
||||
"laravel-vite-plugin": "^1.0.0",
|
||||
"openapi-zod-client": "^1.16.2",
|
||||
"postcss": "^8.4.14",
|
||||
"tailwindcss": "^3.1.0",
|
||||
"typescript": "^5.3.3",
|
||||
@@ -30,8 +32,16 @@
|
||||
"ziggy-js": "^1.8.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.7.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0"
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.5.0",
|
||||
"parse-duration": "^1.1.0",
|
||||
"pinia": "^2.1.7",
|
||||
"radix-vue": "^1.4.9",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"vue-echarts": "^6.6.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_DRIVER" value="array"/>
|
||||
<env name="DB_CONNECTION" value="pgsql"/>
|
||||
<env name="DB_CONNECTION" value="pgsql_test"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
|
||||
@@ -16,20 +16,22 @@ export default defineConfig({
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
reporter: process.env.CI ? 'line' : 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://127.0.0.1:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
trace: process.env.CI ? 'on-first-retry' : 'on',
|
||||
},
|
||||
|
||||
timeout: 10 * 1000,
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const PLAYWRIGHT_BASE_URL =
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? 'http://laravel.test';
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test as baseTest } from '@playwright/test';
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { PLAYWRIGHT_BASE_URL } from './config';
|
||||
@@ -55,11 +55,6 @@ export const test = baseTest.extend<object, { workerStorageState: string }>({
|
||||
// Wait for the final URL to ensure that the cookies are actually set.
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
|
||||
// Alternatively, you can wait until the page reaches a state where all cookies are set.
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboard' })
|
||||
).toBeVisible();
|
||||
|
||||
// End of authentication steps.
|
||||
|
||||
await page.context().storageState({ path: fileName });
|
||||
|
||||
BIN
public/fonts/Outfit-VariableFont_wght.ttf
Normal file
BIN
public/fonts/Outfit-VariableFont_wght.ttf
Normal file
Binary file not shown.
@@ -2,6 +2,18 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
:root{
|
||||
--theme-color-icon-default: #42466C;
|
||||
--theme-color-card-background: #13152B;
|
||||
}
|
||||
|
||||
|
||||
[x-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
src: url('/fonts/Outfit-VariableFont_wght.ttf');
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ defineProps({
|
||||
leave-active-class="transition ease-in duration-1000"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0">
|
||||
<div v-show="on" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div v-show="on" class="text-sm text-muted">
|
||||
<slot />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -15,7 +15,7 @@ import SectionTitle from './SectionTitle.vue';
|
||||
|
||||
<div class="mt-5 md:mt-0 md:col-span-2">
|
||||
<div
|
||||
class="px-4 py-5 sm:p-6 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
class="px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<svg viewBox="0 0 317 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M74.09 30.04V13h-4.14v21H82.1v-3.96h-8.01zM95.379 19v1.77c-1.08-1.35-2.7-2.19-4.89-2.19-3.99 0-7.29 3.45-7.29 7.92s3.3 7.92 7.29 7.92c2.19 0 3.81-.84 4.89-2.19V34h3.87V19h-3.87zm-4.17 11.73c-2.37 0-4.14-1.71-4.14-4.23 0-2.52 1.77-4.23 4.14-4.23 2.4 0 4.17 1.71 4.17 4.23 0 2.52-1.77 4.23-4.17 4.23zM106.628 21.58V19h-3.87v15h3.87v-7.17c0-3.15 2.55-4.05 4.56-3.81V18.7c-1.89 0-3.78.84-4.56 2.88zM124.295 19v1.77c-1.08-1.35-2.7-2.19-4.89-2.19-3.99 0-7.29 3.45-7.29 7.92s3.3 7.92 7.29 7.92c2.19 0 3.81-.84 4.89-2.19V34h3.87V19h-3.87zm-4.17 11.73c-2.37 0-4.14-1.71-4.14-4.23 0-2.52 1.77-4.23 4.14-4.23 2.4 0 4.17 1.71 4.17 4.23 0 2.52-1.77 4.23-4.17 4.23zM141.544 19l-3.66 10.5-3.63-10.5h-4.26l5.7 15h4.41l5.7-15h-4.26zM150.354 28.09h11.31c.09-.51.15-1.02.15-1.59 0-4.41-3.15-7.92-7.59-7.92-4.71 0-7.92 3.45-7.92 7.92s3.18 7.92 8.22 7.92c2.88 0 5.13-1.17 6.54-3.21l-3.12-1.8c-.66.87-1.86 1.5-3.36 1.5-2.04 0-3.69-.84-4.23-2.82zm-.06-3c.45-1.92 1.86-3.03 3.93-3.03 1.62 0 3.24.87 3.72 3.03h-7.65zM164.516 34h3.87V12.1h-3.87V34zM185.248 34.36c3.69 0 6.9-2.01 6.9-6.3V13h-2.1v15.06c0 3.03-2.07 4.26-4.8 4.26-2.19 0-3.93-.78-4.62-2.61l-1.77 1.05c1.05 2.43 3.57 3.6 6.39 3.6zM203.124 18.64c-4.65 0-7.83 3.45-7.83 7.86 0 4.53 3.24 7.86 7.98 7.86 3.03 0 5.34-1.41 6.6-3.45l-1.74-1.02c-.81 1.44-2.46 2.55-4.83 2.55-3.18 0-5.55-1.89-5.97-4.95h13.17c.03-.3.06-.63.06-.93 0-4.11-2.85-7.92-7.44-7.92zm0 1.92c2.58 0 4.98 1.71 5.4 5.01h-11.19c.39-2.94 2.64-5.01 5.79-5.01zM221.224 20.92V19h-4.32v-4.2l-1.98.6V19h-3.15v1.92h3.15v9.09c0 3.6 2.25 4.59 6.3 3.99v-1.74c-2.91.12-4.32.33-4.32-2.25v-9.09h4.32zM225.176 22.93c0-1.62 1.59-2.37 3.15-2.37 1.44 0 2.97.57 3.6 2.1l1.65-.96c-.87-1.86-2.79-3.06-5.25-3.06-3 0-5.13 1.89-5.13 4.29 0 5.52 8.76 3.39 8.76 7.11 0 1.77-1.68 2.4-3.45 2.4-2.01 0-3.57-.99-4.11-2.52l-1.68.99c.75 1.92 2.79 3.45 5.79 3.45 3.21 0 5.43-1.77 5.43-4.32 0-5.52-8.76-3.39-8.76-7.11zM244.603 20.92V19h-4.32v-4.2l-1.98.6V19h-3.15v1.92h3.15v9.09c0 3.6 2.25 4.59 6.3 3.99v-1.74c-2.91.12-4.32.33-4.32-2.25v-9.09h4.32zM249.883 21.49V19h-1.98v15h1.98v-8.34c0-3.72 2.34-4.98 4.74-4.98v-1.92c-1.92 0-3.69.63-4.74 2.73zM263.358 18.64c-4.65 0-7.83 3.45-7.83 7.86 0 4.53 3.24 7.86 7.98 7.86 3.03 0 5.34-1.41 6.6-3.45l-1.74-1.02c-.81 1.44-2.46 2.55-4.83 2.55-3.18 0-5.55-1.89-5.97-4.95h13.17c.03-.3.06-.63.06-.93 0-4.11-2.85-7.92-7.44-7.92zm0 1.92c2.58 0 4.98 1.71 5.4 5.01h-11.19c.39-2.94 2.64-5.01 5.79-5.01zM286.848 19v2.94c-1.26-2.01-3.39-3.3-6.06-3.3-4.23 0-7.74 3.42-7.74 7.86s3.51 7.86 7.74 7.86c2.67 0 4.8-1.29 6.06-3.3V34h1.98V19h-1.98zm-5.91 13.44c-3.33 0-5.91-2.61-5.91-5.94 0-3.33 2.58-5.94 5.91-5.94s5.91 2.61 5.91 5.94c0 3.33-2.58 5.94-5.91 5.94zM309.01 18.64c-1.92 0-3.75.87-4.86 2.73-.84-1.74-2.46-2.73-4.56-2.73-1.8 0-3.42.72-4.59 2.55V19h-1.98v15H295v-8.31c0-3.72 2.16-5.13 4.32-5.13 2.13 0 3.51 1.41 3.51 4.08V34h1.98v-8.31c0-3.72 1.86-5.13 4.17-5.13 2.13 0 3.66 1.41 3.66 4.08V34h1.98v-9.36c0-3.75-2.31-6-5.61-6z"
|
||||
class="fill-black dark:fill-white" />
|
||||
class="fill-white" />
|
||||
<path
|
||||
d="M11.395 44.428C4.557 40.198 0 32.632 0 24 0 10.745 10.745 0 24 0a23.891 23.891 0 0113.997 4.502c-.2 17.907-11.097 33.245-26.602 39.926z"
|
||||
fill="#6875F5" />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
|
||||
class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-card-background">
|
||||
<div>
|
||||
<slot name="logo" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||
class="w-full sm:max-w-md mt-6 px-6 py-4 bg-card-background shadow-md overflow-hidden sm:rounded-lg">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,5 +30,5 @@ const proxyChecked = computed({
|
||||
v-model="proxyChecked"
|
||||
type="checkbox"
|
||||
:value="value"
|
||||
class="rounded dark:bg-gray-900 border-gray-300 dark:border-gray-700 text-indigo-600 shadow-sm focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:focus:ring-offset-gray-800" />
|
||||
class="rounded bg-input-background border-input-border text-indigo-600 shadow-sm focus:ring-indigo-500" />
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user