Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
9cb3aea7be Add checks for placeholder invitation; Fixed bug in member deletion 2025-07-07 16:54:26 +02:00
492 changed files with 7755 additions and 7791 deletions

View File

@@ -80,7 +80,8 @@ GOTENBERG_URL=http://gotenberg:3000
# Local setup
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=54329
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage

View File

@@ -1,11 +1,8 @@
## What does this PR do?
<!--
This project is early stage. The structure and APIs are still subject to change and not stable.
Therefore, we do not currently accept any contributions, unless you are a member of the team.
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
- Fixes #XXXX (GitHub issue number)
## Checklist (DO NOT REMOVE)
- [ ] I read the [contributing guide](https://github.com/solidtime-io/solidtime/blob/main/CONTRIBUTING.md)
- [ ] I signed the [Contributor License Agreement](https://cla-assistant.io/solidtime-io/solidtime).
- [ ] I commented my code, particularly in hard-to-understand areas
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
-->

View File

@@ -1,23 +0,0 @@
name: NPM Format Check
on: [push]
jobs:
format-check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Check code formatting"
run: npm run format:check

View File

@@ -1,27 +0,0 @@
# Ignore build outputs
node_modules/
vendor/
storage/
bootstrap/cache/
public/build/
public/hot/
# Ignore lock files
package-lock.json
composer.lock
# Ignore generated files
*.min.js
*.min.css
# Ignore test results
test-results/
playwright-report/
# Ignore IDE files
.idea/
.vscode/
# Ignore OS files
.DS_Store
Thumbs.db

View File

@@ -3,6 +3,5 @@
"tabWidth": 4,
"singleQuote": true,
"bracketSameLine": true,
"quoteProps": "preserve",
"printWidth": 100
"quoteProps": "preserve"
}

View File

@@ -1,81 +0,0 @@
# Contributing to solidtime
Contributions are greatly apprecited, please make sure to read the rules and vision for solidtime before contributing.
## Rules
### Issues for Bugs, Discussions for Feature requests
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
### Only work on approved issues
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
### Contributor License Agreement
You'll also notice that weve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Dont worry - the process is quick and only takes a few clicks.
We want to be transparent about why we require the CLA and what it means for your contributions and the codebase. Thats why weve written a few paragraphs below outlining our plans and vision for solidtime in the **Vision** part of this document.
### Prevent Duplicate Work
Before you submit a new PR, make sure that none exists already. If you plan to work on an issue, make sure to let us and others know by commenting on the issue/discussion.
### Give context
Tell us what you thinking was behind the decisions you made while drafting the PR. Treat the PR itself as documentation for everyone who wants to go back and understand why certain decisions were made.
### Summarize your PR
Please make sure to include a short summary at the top of your PR to make it easy for us to quickly check what the PR is about, without looking at the code changes.
### Use Github Keywords and Auto-Link Issues
Use phrases like "Closes #123" or "Fixes #123" in the PR description to link the PR with the issue that you are adressing.
### Mention what you tested and how
Explain how you tested and validated the implementation.
### Keep Naming consistent
Look at existing code patterns and use naming conventions that already exist in the code base.
### Testing
We have an exhaustive test-suite of PHPUnit (Backend) and Playwright (Frontend) testing. Whereever applicable please make sure to write add tests to the codebase.
### Linting & Formatting
Make sure to run linting and formatting commands before you commit the changes.
For backend changes:
```
composer fix
composer analyse
```
For frontend changes:
```
npm run lint:fix
npm run format
```
## Vision
We started solidtime to provide an open infrastructure solution for time tracking—one that empowers teams and individuals to fully own their data, instead of depending on proprietary platforms. We believe infrastructure software should be open, accessible, and built to last. However, competing with established market leaders in this space requires long-term financial sustainability.
solidtime is licensed under the AGPL, which we believe is the best available license to strike a balance between openness and financial viability. The AGPL gives us, as the copyright holders, certain exclusive rights that we plan to leverage to fund development. To ensure we retain those rights across the entire codebase, we've put a CLA in place that contributors must sign before submitting code.
One of solidtimes key advantages is that it's built to be self-hostable. This makes it a great solution for organizations like governments, healthcare providers, and enterprises that are required to keep data on their own infrastructure due to regulations or internal policies. These organizations may need custom licenses, integrations, or modifications that aren't suitable for the open-source version. To support them, we offer relicensed versions of solidtime along with support plans.
Well also provide proprietary extensions for solidtime. These will be available to enterprise customers with support plans, but also to individual users or teams who dont need support, at much more accessible price points. For companies running solidtime on their own infrastructure, this is the easiest way to support the project while gaining additional functionality. While we plan to make it easier to build custom extensions in the future, our current APIs are still highly experimental.
Finally - and perhaps most importantly - we offer a hosted SaaS version called solidtime Cloud, for users who cant or dont want to run the software themselves. This version includes proprietary extensions, always runs the latest commit, and includes monitoring and billing features available exclusively on this hosted instance. We expect solidtime Cloud to play a critical role in funding the project long-term.
Having full control over the source codes licensing also gives us the ability to change the license of the main project in the future. That said, we have no plans to do so and would only consider it in extreme cases - for example, if a malicious actor were to directly compete with our hosted service in a way that threatens the sustainability of the project, the legal interpretation of AGPL changes in a way that would make it unreasonable to use for certain companies, or a new similar license gains wide-spread adoption. Regardless, solidtime will always remain free to self-host for individuals and companies who use it as part of their work, and all previous releases will remain licensed under AGPL.
If you are using the open-source version of solidtime and want to support us, the best way to do so is to spread the word.

View File

@@ -35,9 +35,10 @@ If you have a **feature request**, please [**create a discussion**](https://gith
## Contributing
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
Therefore, we do not currently accept any contributions, unless you are a member of the team.
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -26,7 +26,7 @@ class CreateNewUser implements CreatesNewUsers
/**
* Create a newly registered user.
*
* @param array<string, mixed> $input
* @param array<string, string> $input
*
* @throws ValidationException
*/

View File

@@ -6,6 +6,7 @@ namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
@@ -58,7 +59,8 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
$user->updateProfilePhoto($input['photo']);
}
if ($input['email'] !== $user->email) {
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],

View File

@@ -57,7 +57,7 @@ class AddOrganizationMember implements AddsTeamMembers
*/
protected function rules(): array
{
return [
return array_filter([
'email' => [
'required',
'email',
@@ -75,7 +75,7 @@ class AddOrganizationMember implements AddsTeamMembers
Role::Employee->value,
]),
],
];
]);
}
/**

View File

@@ -1,108 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Auth;
use App\Mail\AuthApiTokenExpirationReminderMail;
use App\Mail\AuthApiTokenExpiredMail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
class AuthSendReminderForExpiringApiTokensCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'auth:send-mails-expiring-api-tokens '.
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
}
$this->comment('Sending reminder emails about expiring API tokens...');
$sentMails = 0;
Token::query()
->where('expires_at', '<=', Carbon::now()->addDays(7))
->whereNull('reminder_sent_at')
->with([
'client',
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->isApiToken(true)
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
/** @var Collection<int, Token> $tokens */
foreach ($tokens as $token) {
$user = $token->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new AuthApiTokenExpirationReminderMail($token, $user));
$token->reminder_sent_at = Carbon::now();
$token->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' expiring API token emails...');
$this->comment('Sent emails about expired API tokens');
$sentMails = 0;
Token::query()
->where('expires_at', '<=', Carbon::now())
->whereNull('expired_info_sent_at')
->with([
'client',
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->isApiToken(true)
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
/** @var Collection<int, Token> $tokens */
foreach ($tokens as $token) {
$user = $token->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new AuthApiTokenExpiredMail($token, $user));
$token->expired_info_sent_at = Carbon::now();
$token->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' expired API token emails...');
return self::SUCCESS;
}
}

View File

@@ -18,10 +18,6 @@ class Kernel extends ConsoleKernel
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes();
$schedule->command('auth:send-mails-expiring-api-tokens')
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
->everyTenMinutes();
$schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily();

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryRoundingType: string
{
use LaravelEnumHelper;
case Up = 'up';
case Down = 'down';
case Nearest = 'nearest';
}

View File

@@ -41,7 +41,9 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
return null;
}
$collectingType = $this->openApiTransformer->transform($collectingClassType);
if (! ($collectingType = $this->openApiTransformer->transform($collectingClassType))) {
return null;
}
$newType = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));

View File

@@ -15,7 +15,6 @@ use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -76,8 +75,7 @@ class FailedJobResource extends Resource
->filters([])
->bulkActions([
BulkAction::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry selected')
->label('Retry')
->requiresConfirmation()
->action(function (Collection $records): void {
/** @var FailedJob $record */
@@ -89,13 +87,11 @@ class FailedJobResource extends Resource
->success()
->send();
}),
DeleteBulkAction::make(),
])
->actions([
DeleteAction::make(),
ViewAction::make(),
DeleteAction::make('Delete'),
ViewAction::make('View'),
Action::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry')
->requiresConfirmation()
->action(function (FailedJob $record): void {
@@ -113,6 +109,7 @@ class FailedJobResource extends Resource
return [
'index' => ListFailedJobs::route('/'),
'view' => ViewFailedJobs::route('/{record}'),
];
}
}

View File

@@ -6,8 +6,8 @@ namespace App\Filament\Resources\FailedJobResource\Pages;
use App\Filament\Resources\FailedJobResource;
use App\Models\FailedJob;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
@@ -19,8 +19,7 @@ class ListFailedJobs extends ListRecords
{
return [
Action::make('retry_all')
->icon('heroicon-o-arrow-path')
->label('Retry all')
->label('Retry all failed Jobs')
->requiresConfirmation()
->action(function (): void {
Artisan::call('queue:retry all');
@@ -31,8 +30,7 @@ class ListFailedJobs extends ListRecords
}),
Action::make('delete_all')
->icon('heroicon-o-trash')
->label('Delete all')
->label('Delete all failed Jobs')
->requiresConfirmation()
->color('danger')
->action(function (): void {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Filament\Forms;
use Filament\Forms\Form;
@@ -39,7 +40,7 @@ class TokenResource extends Resource
->label('Name')
->required()
->maxLength(255),
Forms\Components\Select::make('owner_id')
Forms\Components\Select::make('user_id')
->label('User')
->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name'])
@@ -78,12 +79,10 @@ class TokenResource extends Resource
Tables\Columns\TextColumn::make('client.name')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('personal_access_client')
->state(function (Token $token): bool {
return in_array('personal_access', $token->client->grant_types ?? [], true);
})
Tables\Columns\IconColumn::make('client.personal_access_client')
->boolean()
->label('API token?'),
->label('API token?')
->sortable(),
Tables\Columns\IconColumn::make('revoked')
->boolean()
->label('Revoked?')
@@ -105,11 +104,17 @@ class TokenResource extends Resource
->queries(
true: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->isApiToken();
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', true);
});
},
false: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->isApiToken(false);
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', false);
});
},
blank: function (Builder $query) {
/** @var Builder<Token> $query */

View File

@@ -8,12 +8,9 @@ use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
class ApiTokenController extends Controller
{
@@ -31,10 +28,7 @@ class ApiTokenController extends Controller
$user = $this->user();
$tokens = $user->tokens()
->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
})
->where('client_id', '=', config('passport.personal_access_client.id'))
->get();
return new ApiTokenCollection($tokens);
@@ -54,21 +48,15 @@ class ApiTokenController extends Controller
{
$user = $this->user();
try {
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->getToken();
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
} catch (\RuntimeException $exception) {
report($exception);
if (Str::contains($exception->getMessage(), ['Personal access client not found'])) {
throw new PersonalAccessClientIsNotConfiguredException;
}
throw $exception;
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->token;
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
}
/**
@@ -83,10 +71,13 @@ class ApiTokenController extends Controller
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token');
}
@@ -106,10 +97,13 @@ class ApiTokenController extends Controller
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token');
}

View File

@@ -14,8 +14,6 @@ use Illuminate\Http\JsonResponse;
class ChartController extends Controller
{
/**
* Get chart data for the weekly project overview.
*
* @throws AuthorizationException
*
* @operationId weeklyProjectOverview
@@ -33,8 +31,6 @@ class ChartController extends Controller
}
/**
* Get chart data for the latest tasks.
*
* @throws AuthorizationException
*
* @operationId latestTasks
@@ -52,8 +48,6 @@ class ChartController extends Controller
}
/**
* Get chart data for the last seven days.
*
* @throws AuthorizationException
*
* @operationId lastSevenDays
@@ -71,8 +65,6 @@ class ChartController extends Controller
}
/**
* Get chart data for the latest team activity.
*
* @throws AuthorizationException
*
* @operationId latestTeamActivity
@@ -89,8 +81,6 @@ class ChartController extends Controller
}
/**
* Get chart data for daily tracked hours.
*
* @throws AuthorizationException
*
* @operationId dailyTrackedHours
@@ -108,8 +98,6 @@ class ChartController extends Controller
}
/**
* Get chart data for total weekly time.
*
* @throws AuthorizationException
*
* @operationId totalWeeklyTime
@@ -127,8 +115,6 @@ class ChartController extends Controller
}
/**
* Get chart data for total weekly billable time.
*
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableTime
@@ -146,8 +132,6 @@ class ChartController extends Controller
}
/**
* Get chart data for total weekly billable amount.
*
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableAmount
@@ -170,8 +154,6 @@ class ChartController extends Controller
}
/**
* Get chart data for weekly history.
*
* @throws AuthorizationException
*
* @operationId weeklyHistory

View File

@@ -73,9 +73,7 @@ class ReportController extends Controller
false,
$report->properties->start,
$report->properties->end,
true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
true
);
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
@@ -86,9 +84,7 @@ class ReportController extends Controller
true,
$report->properties->start,
$report->properties->end,
true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
true
);
return new DetailedWithDataReportResource($report, $data, $historyData);

View File

@@ -107,8 +107,6 @@ class ReportController extends Controller
}
}
$properties->timezone = $timezone;
$properties->roundingType = $request->getPropertyRoundingType();
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
$report->properties = $properties;
if ($isPublic) {
$report->share_secret = $reportService->generateSecret();

View File

@@ -33,7 +33,6 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
use App\Service\TimeEntryService;
use App\Service\TimezoneService;
use Gotenberg\Exceptions\GotenbergApiErrored;
use Gotenberg\Exceptions\NoOutputFileInResponse;
@@ -48,7 +47,6 @@ use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
@@ -86,8 +84,7 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$totalCount = $timeEntriesQuery->count();
@@ -141,19 +138,10 @@ class TimeEntryController extends Controller
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{
$select = TimeEntry::SELECT_COLUMNS;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
if ($roundingType !== null && $roundingMinutes !== null) {
$select = array_diff($select, ['start', 'end']);
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
}
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->select($select)
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
@@ -187,19 +175,16 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$debug = $request->getDebug();
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([
'task',
'client',
@@ -222,9 +207,8 @@ class TimeEntryController extends Controller
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
$timeEntriesQuery->clone()->reorder()->withOnly([]),
null,
null,
$user->timezone,
@@ -232,9 +216,7 @@ class TimeEntryController extends Controller
false,
null,
null,
$showBillableRate,
$roundingType,
$roundingMinutes,
$showBillableRate
);
$html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(),
@@ -336,15 +318,12 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
@@ -355,9 +334,7 @@ class TimeEntryController extends Controller
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd(),
$showBillableRate,
$roundingType,
$roundingMinutes
$showBillableRate
);
return [
@@ -385,7 +362,6 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
@@ -397,8 +373,6 @@ class TimeEntryController extends Controller
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(),
@@ -409,9 +383,7 @@ class TimeEntryController extends Controller
false,
$request->getStart(),
$request->getEnd(),
$showBillableRate,
$roundingType,
$roundingMinutes
$showBillableRate
);
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(),
@@ -422,9 +394,7 @@ class TimeEntryController extends Controller
true,
$request->getStart(),
$request->getEnd(),
$showBillableRate,
$roundingType,
$roundingMinutes
$showBillableRate
);
$currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
@@ -507,7 +477,7 @@ class TimeEntryController extends Controller
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization');

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\URL;
@@ -19,7 +20,8 @@ class EnsureEmailIsVerified
{
if (! app()->isLocal()) {
if ($request->user() === null ||
(! $request->user()->hasVerifiedEmail())) {
($request->user() instanceof MustVerifyEmail &&
! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson()
? abort(403, 'Your email address is not verified.')
: Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));

View File

@@ -50,7 +50,7 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'billing' => $currentOrganization !== null ? [
'billing' => $billing !== null && $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),

View File

@@ -26,7 +26,7 @@ class ShareInertiaData
{
/** @var PermissionStore $permissions */
$permissions = app(PermissionStore::class);
Inertia::share([
Inertia::share(array_filter([
'jetstream' => function () use ($request) {
/** @var User|null $user */
$user = $request->user();
@@ -101,7 +101,7 @@ class ShareInertiaData
return [$key => $bag->messages()];
})->all();
},
]);
]));
return $next($request);
}

View File

@@ -6,7 +6,6 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
@@ -129,18 +128,6 @@ class ReportStoreRequest extends BaseFormRequest
'nullable',
'timezone:all',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'properties.rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'properties.rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -218,22 +205,4 @@ class ReportStoreRequest extends BaseFormRequest
{
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
}
public function getPropertyRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->input('properties.rounding_type'));
}
public function getPropertyRoundingMinutes(): ?int
{
if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {
return null;
}
return (int) $this->input('properties.rounding_minutes');
}
}

View File

@@ -7,7 +7,6 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -165,18 +164,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -224,22 +211,4 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
{
return ExportFormat::from($this->validated('format'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -147,18 +146,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -186,22 +173,4 @@ class TimeEntryAggregateRequest extends BaseFormRequest
{
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryRoundingType;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -134,18 +133,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -183,22 +170,4 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
return ExportFormat::from($this->validated('format'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -12,10 +11,8 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -26,7 +23,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|RuleContract>>
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
@@ -139,18 +136,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -168,22 +153,4 @@ class TimeEntryIndexRequest extends BaseFormRequest
{
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -8,11 +8,15 @@ use App\Http\Resources\PaginatedResourceCollection;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{
private bool $showBillableRates;
/**
* @param LengthAwarePaginator<Project> $resource
*/
public function __construct($resource, bool $showBillableRates)
{
parent::__construct($resource);

View File

@@ -58,10 +58,6 @@ class DetailedReportResource extends BaseResource
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */
'rounding_type' => $this->resource->properties->roundingType?->value,
/** @var int|null $rounding_minutes Rounding minutes for time entries */
'rounding_minutes' => $this->resource->properties->roundingMinutes,
],
/** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at),

View File

@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class AuthApiTokenExpirationReminderMail extends Mailable
{
use Queueable, SerializesModels;
public Token $token;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Token $token, User $user)
{
$this->token = $token;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.auth-api-expiration-reminder', [
'profileUrl' => URL::to('user/profile'),
'tokenName' => $this->token->name,
])
->subject(__('Your API token will expire in 7 days!'));
}
}

View File

@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class AuthApiTokenExpiredMail extends Mailable
{
use Queueable, SerializesModels;
public Token $token;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Token $token, User $user)
{
$this->token = $token;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.auth-api-token-expired', [
'profileUrl' => URL::to('user/profile'),
'tokenName' => $this->token->name,
])
->subject(__('Your API token has expired!'));
}
}

View File

@@ -16,8 +16,8 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string $event
* @property string $auditable_type
* @property string $auditable_id
* @property array<string, mixed>|null $old_values
* @property array<string, mixed>|null $new_values
* @property array|null $old_values
* @property array|null $new_values
* @property string|null $url
* @property string|null $ip_address
* @property string|null $user_agent

View File

@@ -47,7 +47,7 @@ class Client extends Model implements AuditableContract
];
/**
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, Client>
*/
public function organization(): BelongsTo
{
@@ -55,7 +55,7 @@ class Client extends Model implements AuditableContract
}
/**
* @return HasMany<Project, $this>
* @return HasMany<Project>
*/
public function projects(): HasMany
{

View File

@@ -25,8 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at
* @property-read Organization $organization
* @property-read User $user
* @property-read Collection<int, ProjectMember> $projectMembers
* @property-read Collection<int, TimeEntry> $timeEntries
* @property-read Collection<ProjectMember> $projectMembers
* @property-read Collection<TimeEntry> $timeEntries
*
* @method static MemberFactory factory()
*/
@@ -47,7 +47,7 @@ class Member extends JetstreamMembership implements AuditableContract
protected $table = 'members';
/**
* @return BelongsTo<User, $this>
* @return BelongsTo<User, Member>
*/
public function user(): BelongsTo
{
@@ -55,7 +55,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, Member>
*/
public function organization(): BelongsTo
{
@@ -63,7 +63,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return HasMany<TimeEntry, $this>
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{
@@ -71,7 +71,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return HasMany<ProjectMember, $this>
* @return HasMany<ProjectMember>
*/
public function projectMembers(): HasMany
{

View File

@@ -18,7 +18,6 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated;
@@ -48,7 +47,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property IntervalFormat $interval_format
* @property TimeFormat $time_format
*
* @method HasMany<OrganizationInvitation, $this> teamInvitations()
* @method HasMany<OrganizationInvitation> teamInvitations()
* @method static OrganizationFactory factory()
*/
class Organization extends JetstreamTeam implements AuditableContract
@@ -80,7 +79,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* The attributes that are mass assignable.
*
* @var list<string>
* @var array<int, string>
*/
protected $fillable = [
'name',
@@ -126,7 +125,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* Get all the users that belong to the team.
*
* @return BelongsToMany<User, $this, Pivot, 'membership'>
* @return BelongsToMany<User>
*/
public function users(): BelongsToMany
{
@@ -143,7 +142,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* Get the owner of the team.
*
* @return BelongsTo<User, $this>
* @return BelongsTo<User, Organization>
*/
public function owner(): BelongsTo
{
@@ -151,7 +150,7 @@ class Organization extends JetstreamTeam implements AuditableContract
}
/**
* @return HasMany<Member, $this>
* @return HasMany<Member>
*/
public function members(): HasMany
{
@@ -159,7 +158,7 @@ class Organization extends JetstreamTeam implements AuditableContract
}
/**
* @return BelongsToMany<User, $this, Pivot, 'membership'>
* @return BelongsToMany<User>
*/
public function realUsers(): BelongsToMany
{

View File

@@ -53,7 +53,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
/**
* Get the organization that the invitation belongs to.
*
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, OrganizationInvitation>
*/
public function organization(): BelongsTo
{
@@ -63,7 +63,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
/**
* Get the organization that the invitation belongs to.
*
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, OrganizationInvitation>
*/
public function team(): BelongsTo
{

View File

@@ -4,26 +4,6 @@ declare(strict_types=1);
namespace App\Models\Passport;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Passport\AuthCode as PassportAuthCode;
/**
* @property string $id
* @property string $user_id
* @property string $client_id
* @property string|null $scopes
* @property bool $revoked
* @property Carbon $expires_at
*/
class AuthCode extends PassportAuthCode
{
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
class AuthCode extends PassportAuthCode {}

View File

@@ -5,36 +5,22 @@ declare(strict_types=1);
namespace App\Models\Passport;
use Database\Factories\Passport\ClientFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Carbon;
use Laravel\Passport\Client as PassportClient;
/**
* @property string $id
* @property string|null $owner_id
* @property string|null $owner_type
* @property string|null $user_id
* @property string $name
* @property string|null $secret
* @property string|null $provider
* @property array<string> $grant_types
* @property array<string> $redirect_uris
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string $redirect
* @property bool $personal_access_client
* @property bool $password_client
* @property bool $revoked
*/
class Client extends PassportClient
{
/** @use HasFactory<ClientFactory> */
use HasFactory;
/**
* Create a new factory instance for the model.
*
* @return ClientFactory
*/
protected static function newFactory(): Factory
{
return ClientFactory::new();
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
class PersonalAccessClient extends PassportPersonalAccessClient {}

View File

@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace App\Models\Passport;
use App\Models\User;
use Database\Factories\Passport\TokenFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
@@ -19,15 +17,9 @@ use Laravel\Passport\Token as PassportToken;
* @property null|string $name
* @property array<string> $scopes
* @property bool $revoked
* @property Carbon|null $reminder_sent_at
* @property Carbon|null $expired_info_sent_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $expires_at
* @property-read Client|null $client
* @property-read User|null $user
*
* @method Builder<Token> isApiToken(bool $isApiToken = true)
*/
class Token extends PassportToken
{
@@ -37,60 +29,10 @@ class Token extends PassportToken
/**
* Get the client that the token belongs to.
*
* @return BelongsTo<Client, $this>
* @return BelongsTo<Client, Token>
*/
// @phpstan-ignore method.childReturnType
public function client(): BelongsTo
{
return $this->belongsTo(Client::class, 'client_id', 'id');
}
/**
* Get the user that the token belongs to.
*
* @deprecated Will be removed in a future Laravel version.
*
* @return BelongsTo<User, $this>
*/
// @phpstan-ignore method.childReturnType
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'scopes' => 'array',
'revoked' => 'bool',
'expires_at' => 'datetime',
'reminder_sent_at' => 'datetime',
'expired_info_sent_at' => 'datetime',
];
}
/**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder
{
if ($isApiToken) {
return $query->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
});
} else {
return $query->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonDoesntContain('grant_types', 'personal_access');
});
}
}
}

View File

@@ -137,7 +137,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, Project>
*/
public function organization(): BelongsTo
{
@@ -145,7 +145,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return BelongsTo<Client, $this>
* @return BelongsTo<Client, Project>
*/
public function client(): BelongsTo
{
@@ -153,7 +153,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return HasMany<ProjectMember, $this>
* @return HasMany<ProjectMember>
*/
public function members(): HasMany
{
@@ -161,7 +161,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return HasMany<Task, $this>
* @return HasMany<Task>
*/
public function tasks(): HasMany
{
@@ -169,7 +169,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return HasMany<TimeEntry, $this>
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{

View File

@@ -48,7 +48,7 @@ class ProjectMember extends Model implements AuditableContract
];
/**
* @return BelongsTo<Project, $this>
* @return BelongsTo<Project, ProjectMember>
*/
public function project(): BelongsTo
{
@@ -58,7 +58,7 @@ class ProjectMember extends Model implements AuditableContract
/**
* @deprecated Use member relationship instead
*
* @return BelongsTo<User, $this>
* @return BelongsTo<User, ProjectMember>
*/
public function user(): BelongsTo
{
@@ -66,7 +66,7 @@ class ProjectMember extends Model implements AuditableContract
}
/**
* @return BelongsTo<Member, $this>
* @return BelongsTo<Member, ProjectMember>
*/
public function member(): BelongsTo
{

View File

@@ -55,7 +55,7 @@ class Report extends Model
}
/**
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, Report>
*/
public function organization(): BelongsTo
{

View File

@@ -22,7 +22,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
* @property string $organization_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Collection<int, TimeEntry> $timeEntries
* @property-read Collection<TimeEntry> $timeEntries
* @property-read Organization $organization
*
* @method static TagFactory factory()
@@ -47,7 +47,7 @@ class Tag extends Model implements AuditableContract
];
/**
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, Tag>
*/
public function organization(): BelongsTo
{

View File

@@ -120,7 +120,7 @@ class Task extends Model implements AuditableContract
}
/**
* @return BelongsTo<Project, $this>
* @return BelongsTo<Project, Task>
*/
public function project(): BelongsTo
{
@@ -128,7 +128,7 @@ class Task extends Model implements AuditableContract
}
/**
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, Task>
*/
public function organization(): BelongsTo
{
@@ -136,7 +136,7 @@ class Task extends Model implements AuditableContract
}
/**
* @return HasMany<TimeEntry, $this>
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{

View File

@@ -28,7 +28,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property Carbon|null $end
* @property int|null $billable_rate Billable rate per hour in cents
* @property bool $billable
* @property array<string> $tags
* @property array $tags
* @property string $user_id
* @property string $member_id
* @property bool $is_imported
@@ -45,7 +45,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property-read Client|null $client
* @property string|null $task_id
* @property-read Task|null $task
* @property-read Collection<int, Tag> $tagsRelation
* @property-read Collection<Tag> $tagsRelation
*
* @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory()
@@ -77,26 +77,6 @@ class TimeEntry extends Model implements AuditableContract
'still_active_email_sent_at' => 'datetime',
];
public const array SELECT_COLUMNS = [
'id',
'description',
'start',
'end',
'billable_rate',
'billable',
'user_id',
'organization_id',
'project_id',
'task_id',
'tags',
'created_at',
'updated_at',
'member_id',
'client_id',
'is_imported',
'still_active_email_sent_at',
];
/**
* The attributes that are computed. (f.e. for performance reasons)
* These attributes can be regenerated at any time.
@@ -174,7 +154,7 @@ class TimeEntry extends Model implements AuditableContract
}
/**
* @return BelongsTo<User, $this>
* @return BelongsTo<User, TimeEntry>
*/
public function user(): BelongsTo
{
@@ -182,7 +162,7 @@ class TimeEntry extends Model implements AuditableContract
}
/**
* @return BelongsTo<Member, $this>
* @return BelongsTo<Member, TimeEntry>
*/
public function member(): BelongsTo
{
@@ -190,7 +170,7 @@ class TimeEntry extends Model implements AuditableContract
}
/**
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, TimeEntry>
*/
public function organization(): BelongsTo
{
@@ -198,7 +178,7 @@ class TimeEntry extends Model implements AuditableContract
}
/**
* @return BelongsTo<Project, $this>
* @return BelongsTo<Project, TimeEntry>
*/
public function project(): BelongsTo
{
@@ -206,7 +186,7 @@ class TimeEntry extends Model implements AuditableContract
}
/**
* @return BelongsTo<Task, $this>
* @return BelongsTo<Task, TimeEntry>
*/
public function task(): BelongsTo
{
@@ -216,7 +196,7 @@ class TimeEntry extends Model implements AuditableContract
/**
* This relation can be reconstructed via the task relation. It is only here for performance reasons.
*
* @return BelongsTo<Client, $this>
* @return BelongsTo<Client, TimeEntry>
*/
public function client(): BelongsTo
{

View File

@@ -19,7 +19,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
@@ -28,7 +27,6 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -54,13 +52,13 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Collection<int, TimeEntry> $timeEntries
* @property Member $membership
*
* @method HasMany<Organization, $this> ownedTeams()
* @method HasMany<Organization> ownedTeams()
* @method static UserFactory factory()
* @method static Builder<User> query()
* @method Builder<User> belongsToOrganization(Organization $organization)
* @method Builder<User> active()
*/
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail, OAuthenticatable
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail
{
use CustomAuditable;
use HasApiTokens;
@@ -77,7 +75,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/**
* The attributes that are mass assignable.
*
* @var list<string>
* @var array<int, string>
*/
protected $fillable = [
'name',
@@ -88,7 +86,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
* @var array<int, string>
*/
protected $hidden = [
'password',
@@ -145,7 +143,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return BelongsToMany<Organization, $this, Pivot, 'membership'>
* @return BelongsToMany<Organization>
*/
public function organizations(): BelongsToMany
{
@@ -160,7 +158,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return HasMany<TimeEntry, $this>
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{
@@ -168,7 +166,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return BelongsTo<Organization, $this>
* @return BelongsTo<Organization, User>
*/
public function currentOrganization(): BelongsTo
{
@@ -176,7 +174,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return HasMany<ProjectMember, $this>
* @return HasMany<ProjectMember>
*/
public function projectMembers(): HasMany
{
@@ -184,7 +182,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return HasMany<Token, $this>
* @return HasMany<Token>
*/
public function accessTokens(): HasMany
{
@@ -192,13 +190,24 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return HasMany<AuthCode, $this>
* @return HasMany<AuthCode>
*/
public function authCodes(): HasMany
{
return $this->hasMany(AuthCode::class);
}
/**
* Get the access tokens for the user.
*
* @return HasMany<Token>
*/
public function tokens(): HasMany
{
return $this->hasMany(Token::class, 'user_id')
->orderBy('created_at', 'desc');
}
/**
* @param Builder<User> $builder
*/

View File

@@ -7,6 +7,7 @@ namespace App\Providers;
use App\Models\Organization;
use App\Models\Passport\AuthCode;
use App\Models\Passport\Client;
use App\Models\Passport\PersonalAccessClient;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use App\Policies\OrganizationPolicy;
@@ -50,8 +51,7 @@ class AuthServiceProvider extends ServiceProvider
Passport::useRefreshTokenModel(RefreshToken::class);
Passport::useAuthCodeModel(AuthCode::class);
Passport::useClientModel(Client::class);
Passport::authorizationView('auth.oauth.authorize');
Passport::usePersonalAccessClientModel(PersonalAccessClient::class);
// Passport::tokensExpireIn(now()->addDays(15));
// Passport::refreshTokensExpireIn(now()->addDays(30));

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use Brick\Money\ISOCurrencyProvider;
use Brick\Money\Money;
class CurrencyService
@@ -375,12 +374,4 @@ class CurrencyService
return $currencyCode;
}
public function getRandomCurrencyCode(): string
{
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencyCodes = array_keys($currencies);
return $currencyCodes[array_rand($currencyCodes)];
}
}

View File

@@ -6,7 +6,6 @@ namespace App\Service\Dto;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
@@ -60,10 +59,6 @@ class ReportPropertiesDto implements Castable
*/
public ?Collection $taskIds = null;
public ?TimeEntryRoundingType $roundingType = null;
public ?int $roundingMinutes = null;
/**
* Get the caster class to use when casting from / to this cast target.
*
@@ -120,14 +115,13 @@ class ReportPropertiesDto implements Castable
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
$dto->weekStart = Weekday::from($data->weekStart);
$dto->timezone = $data->timezone;
// Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
return $dto;
}
/**
* @param ReportPropertiesDto $value
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (! ($value instanceof ReportPropertiesDto)) {
@@ -149,8 +143,6 @@ class ReportPropertiesDto implements Castable
'historyGroup' => $value->historyGroup->value,
'weekStart' => $value->weekStart->value,
'timezone' => $value->timezone,
'roundingType' => $value->roundingType?->value,
'roundingMinutes' => $value->roundingMinutes,
];
$jsonString = json_encode($data);

View File

@@ -71,7 +71,7 @@ class PermissionStore
/** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role);
return $roleObj->permissions ?? [];
return $roleObj?->permissions ?? [];
}
/**

View File

@@ -6,7 +6,6 @@ namespace App\Service;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
@@ -42,7 +41,7 @@ class TimeEntryAggregationService
* cost: int|null
* }
*/
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
{
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
$group1Select = null;
@@ -57,14 +56,15 @@ class TimeEntryAggregationService
}
}
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
$timeEntriesQuery->selectRaw(
($group1Select !== null ? $group1Select.' as group_1,' : '').
($group2Select !== null ? $group2Select.' as group_2,' : '').
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'.
' round(
sum(
extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60)
)
) as cost'
);
if ($groupBy !== null) {
$timeEntriesQuery->groupBy($groupBy);
@@ -164,9 +164,9 @@ class TimeEntryAggregationService
* cost: int|null
* }
*/
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
{
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
$keysGroup1 = [];
$keysGroup2 = [];

View File

@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\TimeEntryRoundingType;
use Illuminate\Support\Carbon;
use LogicException;
class TimeEntryService
{
public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
{
if ($roundingType === null || $roundingMinutes === null) {
return 'start';
}
if ($roundingMinutes < 1) {
throw new LogicException('Rounding minutes must be greater than 0');
}
return 'date_bin(\'1 minutes\', start, TIMESTAMP \'1970-01-01\')';
}
public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
{
if ($roundingType === null || $roundingMinutes === null) {
return 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
}
if ($roundingMinutes < 1) {
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
}
}
}

View File

@@ -11,31 +11,31 @@
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.12.2",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.4.0",
"flowframe/laravel-trend": "^0.3.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^2.0.3",
"inertiajs/inertia-laravel": "^1.0",
"korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0",
"laravel/framework": "^12.19.3",
"laravel/framework": "^11.16.0",
"laravel/jetstream": "^5.0",
"laravel/octane": "^2.3",
"laravel/passport": "^13.0.5",
"laravel/passport": "^12.0",
"laravel/tinker": "^2.8",
"league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3",
"maatwebsite/excel": "^3.1",
"novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^12.0.4",
"owen-it/laravel-auditing": "^14.0.0",
"pxlrbt/filament-environment-indicator": "^2.1.0",
"nwidart/laravel-modules": "^11.0.11",
"owen-it/laravel-auditing": "^13.6",
"pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2",
"staudenmeir/eloquent-json-relations": "^1.1",
"stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0",
"tpetry/laravel-postgresql-enhanced": "^3.0.0",
"tpetry/laravel-postgresql-enhanced": "^2.0.0",
"wikimedia/composer-merge-plugin": "^2.1.0"
},
"require-dev": {
@@ -43,13 +43,14 @@
"brianium/paratest": "^7.3",
"fakerphp/faker": "^1.9.1",
"fumeapp/modeltyper": "^3.0",
"larastan/larastan": "^3.5.0",
"phpstan/phpstan": "1.12.0",
"larastan/larastan": "^2.0",
"laravel/pint": "^1.0",
"laravel/sail": "^1.18",
"laravel/telescope": "^5.0",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^8.1",
"phpunit/phpunit": "^12",
"phpunit/phpunit": "^11",
"spatie/laravel-ignition": "^2.0",
"timacdonald/log-fake": "^2.1"
},
@@ -118,8 +119,7 @@
"extra": {
"laravel": {
"dont-discover": [
"laravel/telescope",
"nwidart/laravel-modules"
"laravel/telescope"
]
}
},

2470
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\LaravelModulesServiceProvider;
return [
@@ -198,7 +197,6 @@ return [
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
LaravelModulesServiceProvider::class,
])->toArray(),
/*

View File

@@ -34,15 +34,31 @@ return [
/*
|--------------------------------------------------------------------------
| Passport Database Connection
| Client UUIDs
|--------------------------------------------------------------------------
|
| By default, Passport's models will utilize your application's default
| database connection. If you wish to use a different connection you
| may specify the configured name of the database connection here.
| By default, Passport uses auto-incrementing primary keys when assigning
| IDs to clients. However, if Passport is installed using the provided
| --uuids switch, this will be set to "true" and UUIDs will be used.
|
*/
'connection' => env('PASSPORT_CONNECTION'),
'client_uuids' => true,
/*
|--------------------------------------------------------------------------
| Personal Access Client
|--------------------------------------------------------------------------
|
| If you enable client hashing, you should set the personal access client
| ID and unhashed secret within your environment file. The values will
| get used while issuing fresh personal access tokens to your users.
|
*/
'personal_access_client' => [
'id' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_ID'),
'secret' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET'),
],
];

View File

@@ -6,7 +6,6 @@ return [
'tasks' => [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true),
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),

View File

@@ -11,7 +11,6 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Models\Organization;
use App\Models\User;
use App\Service\CurrencyService;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@@ -28,7 +27,7 @@ class OrganizationFactory extends Factory
{
return [
'name' => $this->faker->unique()->company(),
'currency' => app(CurrencyService::class)->getRandomCurrencyCode(),
'currency' => $this->faker->currencyCode(),
'billable_rate' => null,
'user_id' => User::factory(),
'personal_team' => true,

View File

@@ -7,12 +7,11 @@ namespace Database\Factories\Passport;
use App\Models\Passport\Client;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Laravel\Passport\Database\Factories\ClientFactory as BaseClientFactory;
/**
* @extends Factory<Client>
*/
class ClientFactory extends BaseClientFactory
class ClientFactory extends Factory
{
/**
* Define the model's default state.
@@ -23,40 +22,24 @@ class ClientFactory extends BaseClientFactory
{
return [
'id' => $this->faker->uuid,
'owner_id' => null,
'owner_type' => null,
'user_id' => null,
'name' => $this->faker->company(),
'secret' => $this->faker->regexify('[A-Za-z]{40}'),
'provider' => 'users',
'redirect_uris' => [$this->faker->url()],
'grant_types' => [],
'redirect' => $this->faker->url(),
'personal_access_client' => false,
'password_client' => false,
'revoked' => false,
'created_at' => $this->faker->dateTime(),
'updated_at' => $this->faker->dateTime(),
];
}
public function desktopClient(): self
{
return $this->state(fn (array $attributes) => [
'name' => 'Desktop',
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'authorization_code', 'implicit'],
]);
}
public function apiClient(): self
{
return $this->state(fn (array $attributes) => [
'name' => 'API',
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'client_credentials', 'personal_access'],
]);
}
public function personalAccessClient(): self
{
return $this->state(function (array $attributes) {
return [
'grant_types' => ['personal_access'],
'personal_access_client' => true,
];
});
}
@@ -65,8 +48,7 @@ class ClientFactory extends BaseClientFactory
{
return $this->state(function (array $attributes) use ($user): array {
return [
'owner_id' => $user->getKey(),
'owner_type' => (new User)->getMorphClass(),
'user_id' => $user->getKey(),
];
});
}

View File

@@ -31,8 +31,6 @@ class TokenFactory extends Factory
'created_at' => $this->faker->dateTime,
'updated_at' => $this->faker->dateTime,
'expires_at' => $this->faker->dateTime,
'reminder_sent_at' => null,
'expired_info_sent_at' => null,
];
}

View File

@@ -153,16 +153,6 @@ class TimeEntryFactory extends Factory
});
}
public function endWithDuration(Carbon $end, int $durationInSeconds): self
{
return $this->state(function (array $attributes) use ($end, $durationInSeconds): array {
return [
'start' => $end->copy()->utc()->subSeconds($durationInSeconds),
'end' => $end->copy()->utc(),
];
});
}
public function start(Carbon $start): self
{
return $this->state(function (array $attributes) use ($start): array {

View File

@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_device_codes', function (Blueprint $table): void {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id')->index();
$table->char('user_code', 8)->unique();
$table->text('scopes');
$table->boolean('revoked');
$table->dateTime('user_approved_at')->nullable();
$table->dateTime('last_polled_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_device_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::drop('oauth_personal_access_clients');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::create('oauth_personal_access_clients', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->uuid('client_id');
$table->foreign('client_id')
->references('id')
->on('oauth_clients')
->onDelete('restrict')
->onUpdate('cascade');
$table->timestamps();
});
}
};

View File

@@ -1,115 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('oauth_clients')->update(['provider' => 'users']); // Change default provider if necessary
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->text('grant_types')->default('[]')->after('provider');
$table->text('redirect_uris')->default('[]');
$table->renameColumn('user_id', 'owner_id');
$table->string('owner_type')->after('owner_id')->nullable();
});
DB::table('oauth_clients')
->where('redirect', '=', 'http://localhost')
->where('personal_access_client', '=', true)
->update(['redirect' => '']);
DB::table('oauth_clients')
->whereNotNull('owner_id')
->update(['owner_type' => 'user']); // Value might be class name of the owner model, depends on if you use "enforceMorphMap"
DB::table('oauth_clients')->eachById(function ($client): void {
$grantTypes = ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'];
$confidential = ! empty($client->secret);
$noRedirect = empty($client->redirect);
$redirectUris = $noRedirect ? [] : [$client->redirect];
$firstParty = empty($client->owner_id);
if (! $noRedirect) {
$grantTypes[] = 'authorization_code';
$grantTypes[] = 'implicit';
}
if ($confidential && $firstParty) {
$grantTypes[] = 'client_credentials';
}
if ($client->personal_access_client && $confidential) {
$grantTypes[] = 'personal_access';
}
if ($client->password_client) {
$grantTypes[] = 'password';
}
DB::table('oauth_clients')
->where('id', $client->id)
->update([
'redirect_uris' => $redirectUris,
'grant_types' => $grantTypes,
]);
});
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->dropForeign(['user_id']);
$table->index(['owner_id', 'owner_type']);
$table->dropColumn('redirect');
$table->dropColumn('personal_access_client');
$table->dropColumn('password_client');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->dropIndex(['owner_id', 'owner_type']);
$table->renameColumn('owner_id', 'user_id');
$table->foreign('user_id')
->on('users')
->references('id')
->onDelete('cascade')
->onUpdate('cascade');
$table->string('redirect')->nullable();
$table->boolean('personal_access_client')->default(false);
$table->boolean('password_client')->default(false);
});
DB::table('oauth_clients')->eachById(function ($client): void {
$redirectUris = json_decode($client->redirect_uris);
$grantTypes = json_decode($client->grant_types);
DB::table('oauth_clients')
->where('id', $client->id)
->update([
'redirect' => $redirectUris[0] ?? '', // redirect not nullable
'password_client' => in_array('password', $grantTypes, true)
&& in_array('refresh_token', $grantTypes, true),
'personal_access_client' => in_array('personal_access', $grantTypes, true),
]);
});
Schema::table('oauth_clients', function (Blueprint $table): void {
$table->dropColumn(['grant_types', 'redirect_uris', 'owner_type']);
$table->string('redirect')->nullable(false)->change();
$table->boolean('personal_access_client')->default(null)->change();
$table->boolean('password_client')->default(null)->change();
});
}
};

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// This could be optimized to run all the updates in the eachById
DB::table('oauth_clients')->whereNotNull('secret')->eachById(function ($client): void {
$secret = $client->secret;
if (Hash::isHashed($secret) && ! Hash::needsRehash($secret)) {
return; // Already hashed and not needing rehash
}
DB::table('oauth_clients')
->where('id', $client->id)
->update([
'secret' => Hash::make($secret),
]);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// This can not be reversed without a backup of the original secrets, for security reasons.
}
};

View File

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

View File

@@ -24,6 +24,7 @@ use Illuminate\Support\Facades\DB;
use Laravel\Passport\AuthCode;
use Laravel\Passport\Client as PassportClient;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\PersonalAccessClient;
use Laravel\Passport\RefreshToken;
use Laravel\Passport\Token;
@@ -36,18 +37,6 @@ class DatabaseSeeder extends Seeder
{
$this->deleteAll();
app(ClientRepository::class)->createAuthorizationCodeGrantClient(
name: 'Desktop App',
redirectUris: ['solidtime://oauth/callback'],
confidential: false, // TODO: ?
enableDeviceFlow: false, // TODO: ?
);
// TODO: grant_types ? migration?
// app(ClientRepository::class)->createPersonalAccessGrantClient('API');
/*
app(ClientRepository::class)->create(
null,
'desktop',
@@ -57,16 +46,17 @@ class DatabaseSeeder extends Seeder
false,
false
);
*/
$personalAccessClient = new PassportClient;
$personalAccessClient->id = config('passport.personal_access_client.id');
$personalAccessClient->secret = config('passport.personal_access_client.secret');
$personalAccessClient->name = 'API';
$personalAccessClient->redirect_uris = ['http://localhost'];
$personalAccessClient->redirect = 'http://localhost';
$personalAccessClient->user_id = null;
$personalAccessClient->revoked = false;
$personalAccessClient->provider = 'users';
$personalAccessClient->grant_types = ['personal_access'];
$personalAccessClient->provider = null;
$personalAccessClient->personal_access_client = true;
$personalAccessClient->password_client = false;
$personalAccessClient->save();
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
@@ -207,6 +197,7 @@ class DatabaseSeeder extends Seeder
DB::table((new RefreshToken)->getTable())->delete();
DB::table((new Token)->getTable())->delete();
DB::table((new AuthCode)->getTable())->delete();
DB::table((new PersonalAccessClient)->getTable())->delete();
DB::table((new PassportClient)->getTable())->delete();
// Internal tables

View File

@@ -5,6 +5,8 @@ services:
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP}'
ports:
- '${FORWARD_WEB_PORT:-8083}:80'
image: sail-8.3/app
labels:
- "traefik.enable=true"

View File

@@ -1,30 +1,47 @@
# Accepted values: 8.3 - 8.2
ARG PHP_VERSION=8.3
ARG FRANKENPHP_VERSION=1.8
ARG COMPOSER_VERSION=2.8
ARG BUN_VERSION="latest"
ARG APP_ENV
ARG FRANKENPHP_VERSION=latest
ARG COMPOSER_VERSION=latest
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
###########################################
# Build frontend assets with NPM
###########################################
#ARG NODE_VERSION=20-alpine
#
#FROM node:${NODE_VERSION} AS build
#
#ENV ROOT=/var/www/html
#
#WORKDIR ${ROOT}
#
#RUN npm config set update-notifier false && npm set progress=false
#
#COPY package*.json ./
#
#RUN if [ -f $ROOT/package-lock.json ]; \
# then \
# npm ci --loglevel=error --no-audit; \
# else \
# npm install --loglevel=error --no-audit; \
# fi
#
#COPY . .
#
#RUN npm run build
###########################################
FROM composer:${COMPOSER_VERSION} AS vendor
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-builder-php${PHP_VERSION} AS upstream
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
COPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy
RUN CGO_ENABLED=1 \
XCADDY_SETCAP=1 \
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
CGO_CFLAGS=$(php-config --includes) \
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
xcaddy build \
--output /usr/local/bin/frankenphp \
--with github.com/dunglas/frankenphp=./ \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
--with github.com/dunglas/caddy-cbrotli
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
ARG DOCKER_FILES_BASE_PATH
ARG TARGETPLATFORM
LABEL maintainer="solidtime <hello@solidtime.io>"
LABEL org.opencontainers.image.title="solidtime"
@@ -36,22 +53,18 @@ ARG WWWUSER=1000
ARG WWWGROUP=1000
ARG TZ=UTC
ARG APP_DIR=/var/www/html
ARG APP_ENV
ARG APP_HOST
ARG DOCKER_FILES_BASE_PATH
ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-color \
WITH_HORIZON=false \
WITH_SCHEDULER=false \
OCTANE_SERVER=frankenphp \
TZ=${TZ} \
USER=octane \
ROOT=${APP_DIR} \
APP_ENV=${APP_ENV} \
COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=24 \
XDG_CONFIG_HOME=${APP_DIR}/.config \
XDG_DATA_HOME=${APP_DIR}/.data \
SERVER_NAME=${APP_HOST}
XDG_DATA_HOME=${APP_DIR}/.data
WORKDIR ${ROOT}
@@ -65,16 +78,14 @@ RUN apt-get update; \
apt-get install -yqq --no-install-recommends --show-progress \
apt-utils \
curl \
gcc \
wget \
vim \
git \
nano \
ncdu \
procps \
unzip \
ca-certificates \
supervisor \
libsodium-dev \
libbrotli-dev \
# Install PHP extensions (included with dunglas/frankenphp)
&& install-php-extensions \
bz2 \
@@ -88,8 +99,6 @@ RUN apt-get update; \
exif \
pdo_mysql \
zip \
uv \
vips \
intl \
gd \
redis \
@@ -119,34 +128,27 @@ RUN arch="$(uname -m)" \
RUN userdel --remove --force www-data \
&& groupadd --force -g ${WWWGROUP} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
&& setcap -r /usr/local/bin/frankenphp
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER}
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
&& chmod -R a+rw ${ROOT} /var/{log,run}
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane-default.ini
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php-arm.ini ${PHP_INI_DIR}/conf.d/99-octane-arm.ini
RUN echo "TARGETPLATFORM is equal to ${TARGETPLATFORM}"
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
rm ${PHP_INI_DIR}/conf.d/99-octane-default.ini; \
else \
rm ${PHP_INI_DIR}/conf.d/99-octane-arm.ini; \
fi
USER ${USER}
COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
###########################################
#FROM base AS common
#
#USER ${USER}
#
#COPY --link --chown=${WWWUSER}:${WWWUSER} . .
COPY --chown=${USER}:${USER} --from=vendor /usr/bin/composer /usr/bin/composer
#COPY --chown=${USER}:${USER} composer.json composer.lock ./
#
#RUN composer install \
# --no-dev \
@@ -156,47 +158,22 @@ RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
# --no-scripts \
# --audit
###########################################
# Build frontend assets with Bun
###########################################
#FROM oven/bun:${BUN_VERSION} AS build
#
#ARG APP_ENV
#
#ENV ROOT=/var/www/html \
# APP_ENV=${APP_ENV} \
# NODE_ENV=${APP_ENV:-production}
#
#WORKDIR ${ROOT}
#
#COPY --link package.json bun.lock* ./
#
#RUN bun install --frozen-lockfile
#
#COPY --link . .
#COPY --link --from=common ${ROOT}/vendor vendor
#
#RUN bun run build
###########################################
#FROM common AS runner
USER ${USER}
ENV WITH_HORIZON=false \
WITH_SCHEDULER=false \
WITH_REVERB=false
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
COPY --chown=${USER}:${USER} . .
#COPY --chown=${USER}:${USER} --from=build ${ROOT}/public public
RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \
storage/logs \
bootstrap/cache && chmod -R a+rw storage
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/supervisor/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
# FrankenPHP embedded PHP configuration
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/php.ini
#RUN composer install \
# --classmap-authoritative \
# --no-interaction \
@@ -206,9 +183,12 @@ RUN mkdir -p \
RUN cat .env
#RUN php artisan env
RUN php artisan storage:link
RUN chmod +x /usr/local/bin/start-container
RUN cat ${DOCKER_FILES_BASE_PATH}deployment/utilities.sh >> ~/.bashrc
EXPOSE 8000
ENTRYPOINT ["start-container"]
#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1

View File

@@ -1,35 +0,0 @@
#!/usr/bin/env sh
set -e
container_mode=${CONTAINER_MODE:-"http"}
if [ "${container_mode}" = "http" ]; then
php "${ROOT}/artisan" octane:status
elif [ "${container_mode}" = "horizon" ]; then
php "${ROOT}/artisan" horizon:status
elif [ "${container_mode}" = "scheduler" ]; then
if [ "$(supervisorctl status scheduler:scheduler_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
elif [ "${container_mode}" = "reverb" ]; then
if [ "$(supervisorctl status reverb:reverb_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
elif [ "${container_mode}" = "worker" ]; then
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
else
echo "Container mode mismatched."
exit 1
fi

View File

@@ -1,68 +0,0 @@
{
{$CADDY_GLOBAL_OPTIONS}
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
frankenphp {
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
}
metrics {
per_host
}
servers {
protocols h1
}
}
{$CADDY_EXTRA_CONFIG}
{$CADDY_SERVER_SERVER_NAME} {
log {
level WARN
format filter {
wrap {$CADDY_SERVER_LOGGER}
fields {
uri query {
replace authorization REDACTED
}
}
}
}
route {
root * "{$APP_PUBLIC_PATH}"
encode zstd br gzip
{$CADDY_SERVER_EXTRA_DIRECTIVES}
request_body {
max_size 500MB
}
@static {
file
path *.js *.css *.jpg *.jpeg *.webp *.weba *.webm *.gif *.png *.ico *.cur *.gz *.svg *.svgz *.mp4 *.mp3 *.ogg *.ogv *.htc *.woff2 *.woff
}
@staticshort {
file
path *.json *.xml *.rss
}
header @static Cache-Control "public, immutable, stale-while-revalidate, max-age=31536000"
header @staticshort Cache-Control "no-cache, max-age=3600"
@rejected `path('*.bak', '*.conf', '*.dist', '*.fla', '*.ini', '*.inc', '*.inci', '*.log', '*.orig', '*.psd', '*.sh', '*.sql', '*.swo', '*.swp', '*.swop', '*/.*') && !path('*/.well-known/*')`
error @rejected 401
php_server {
index frankenphp-worker.php
try_files {path} frankenphp-worker.php
resolve_root_symlink
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
[PHP]
post_max_size = 100M
upload_max_filesize = 100M
expose_php = 0
realpath_cache_size = 16M
realpath_cache_ttl = 360
max_input_time = 5
[Opcache]
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 256M
opcache.use_cwd = 0
opcache.max_file_size = 0
opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0
opcache.file_update_protection = 0
opcache.interned_strings_buffer = 16
opcache.file_cache = 60
[JIT]
opcache.jit_buffer_size = 128M
opcache.jit = disable
opcache.jit_prof_threshold = 0.001
opcache.jit_max_root_traces = 2048
opcache.jit_max_side_traces = 256
[zlib]
zlib.output_compression = On
zlib.output_compression_level = 9

View File

@@ -5,8 +5,6 @@ expose_php = 0
realpath_cache_size = 16M
realpath_cache_ttl = 360
max_input_time = 5
register_argc_argv = 0
date.timezone = ${TZ:-UTC}
[Opcache]
opcache.enable = 1
@@ -18,6 +16,7 @@ opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0
opcache.file_update_protection = 0
opcache.interned_strings_buffer = 16
opcache.file_cache = 60
[JIT]
opcache.jit_buffer_size = 128M

View File

@@ -4,49 +4,41 @@ set -e
container_mode=${CONTAINER_MODE:-"http"}
octane_server=${OCTANE_SERVER}
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
echo "Container mode: $container_mode"
initialStuff() {
echo "Container mode: $container_mode"
if [ ${auto_db_migrate} = "true" ]; then
echo "Auto database migration enabled."
php artisan migrate --isolated --force
fi
php artisan storage:link; \
php artisan optimize:clear; \
php artisan optimize;
php artisan event:cache; \
php artisan config:cache; \
php artisan route:cache;
}
if [ "$1" != "" ]; then
exec "$@"
elif [ "${container_mode}" = "http" ]; then
initialStuff
elif [ ${container_mode} = "http" ]; then
echo "Octane Server: $octane_server"
if [ "${octane_server}" = "frankenphp" ]; then
initialStuff
if [ ${octane_server} = "frankenphp" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
elif [ "${octane_server}" = "swoole" ]; then
elif [ ${octane_server} = "swoole" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
elif [ "${octane_server}" = "roadrunner" ]; then
elif [ ${octane_server} = "roadrunner" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
else
echo "Invalid Octane server supplied."
exit 1
fi
elif [ "${container_mode}" = "horizon" ]; then
elif [ ${container_mode} = "horizon" ]; then
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.horizon.conf
elif [ "${container_mode}" = "reverb" ]; then
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.reverb.conf
elif [ "${container_mode}" = "scheduler" ]; then
elif [ ${container_mode} = "scheduler" ]; then
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.scheduler.conf
elif [ "${container_mode}" = "worker" ]; then
if [ -z "${WORKER_COMMAND}" ]; then
echo "WORKER_COMMAND is undefined."
exit 1
fi
elif [ ${container_mode} = "worker" ]; then
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.worker.conf
else

View File

@@ -1,13 +1,14 @@
[supervisord]
nodaemon = true
user = %(ENV_USER)s
logfile = /var/log/supervisor/supervisord.log
pidfile = /var/run/supervisord.pid
nodaemon=true
user=%(ENV_USER)s
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[unix_http_server]
file=/var/run/supervisor.sock
[supervisorctl]
[inet_http_server]
port = 127.0.0.1:9001
serverurl=unix:///var/run/supervisor.sock
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface

View File

@@ -1,14 +1,14 @@
[program:horizon]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
stopwaitsecs = 3600
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopwaitsecs=3600
[include]
files = /etc/supervisord.conf
files=/etc/supervisor/supervisord.conf

View File

@@ -1,14 +0,0 @@
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
minfds = 10000
[include]
files = /etc/supervisord.conf

View File

@@ -1,26 +1,26 @@
[program:scheduler]
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = true
autorestart = false
startsecs = 0
startretries = 1
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=true
autorestart=false
startsecs=0
startretries=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[include]
files = /etc/supervisord.conf
files=/etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,42 @@
[supervisord]
nodaemon=true
user=%(ENV_USER)s
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=swoole --host=0.0.0.0 --port=8000
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/horizon.log
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/scheduler.log
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
stdout_logfile=%(ENV_ROOT)s/scheduler.log

View File

@@ -1,13 +1,13 @@
[program:worker]
process_name = %(program_name)s_%(process_num)s
command = %(ENV_WORKER_COMMAND)s
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
process_name=%(program_name)s_%(process_num)02d
command=%(ENV_WORKER_COMMAND)s
user=%(ENV_USER)s
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[include]
files = /etc/supervisord.conf
files=/etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,12 @@
tinker() {
if [ -z "$1" ]; then
php artisan tinker
else
php artisan tinker --execute="\"dd($1);\""
fi
}
# Commonly used aliases
alias ..="cd .."
alias ...="cd ../.."
alias art="php artisan"

View File

@@ -7,8 +7,11 @@ async function goToProjectsOverview(page: Page) {
}
// Create new project via modal
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
test('test that creating and deleting a new client via the modal works', async ({
page,
}) => {
const newClientName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Client' }).click();
await page.getByPlaceholder('Client Name').fill(newClientName);
@@ -25,9 +28,13 @@ test('test that creating and deleting a new client via the modal works', async (
]);
await expect(page.getByTestId('client_table')).toContainText(newClientName);
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
const moreButton = page.locator(
"[aria-label='Actions for Client " + newClientName + "']"
);
moreButton.click();
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
const deleteButton = page.locator(
"[aria-label='Delete Client " + newClientName + "']"
);
await Promise.all([
deleteButton.click(),
@@ -38,7 +45,9 @@ test('test that creating and deleting a new client via the modal works', async (
response.status() === 204
),
]);
await expect(page.getByTestId('client_table')).not.toContainText(newClientName);
await expect(page.getByTestId('client_table')).not.toContainText(
newClientName
);
});
test('test that archiving and unarchiving clients works', async ({ page }) => {

View File

@@ -22,8 +22,12 @@ test('test that new manager can be invited', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Manager' }).click();
await Promise.all([
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
]);
});
@@ -34,8 +38,12 @@ test('test that new employee can be invited', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
await expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
await expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
]);
});
@@ -46,8 +54,12 @@ test('test that new admin can be invited', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
await page.getByRole('button', { name: 'Administrator' }).click();
await Promise.all([
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
expect(page.getByRole('main')).toContainText(`new+${adminId}@admin.test`),
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
expect(page.getByRole('main')).toContainText(
`new+${adminId}@admin.test`
),
]);
});
test('test that error shows if no role is selected', async ({ page }) => {
@@ -57,7 +69,9 @@ test('test that error shows if no role is selected', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
await Promise.all([
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
expect(page.getByText('Please select a role')).toBeVisible(),
]);
});
@@ -71,7 +85,9 @@ test('test that organization billable rate can be updated with all existing time
await page.getByRole('menuitem').getByText('Edit').click();
await page.getByText('Organization Default Rate').click();
await page.getByText('Custom Rate').click();
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
await page
.getByPlaceholder('Billable Rate')
.fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Member' }).click();
await Promise.all([
@@ -87,7 +103,8 @@ test('test that organization billable rate can be updated with all existing time
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate === newBillableRate * 100
(await response.json()).data.billable_rate ===
newBillableRate * 100
),
]);
});

View File

@@ -35,9 +35,9 @@ test('test that organization name can be updated', async ({ page }) => {
await page.getByLabel('Organization Name').fill('NEW ORG NAME');
await page.getByLabel('Organization Name').press('Enter');
await page.getByLabel('Organization Name').press('Meta+r');
await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText(
'NEW ORG NAME'
);
await expect(
page.locator('[data-testid="organization_switcher"]:visible')
).toContainText('NEW ORG NAME');
});
test('test that organization billable rate can be updated with all existing time entries', async ({
@@ -46,7 +46,9 @@ test('test that organization billable rate can be updated with all existing time
await goToOrganizationSettings(page);
const newBillableRate = Math.round(Math.random() * 10000);
await page.getByLabel('Organization Billable Rate').click();
await page.getByLabel('Organization Billable Rate').fill(newBillableRate.toString());
await page
.getByLabel('Organization Billable Rate')
.fill(newBillableRate.toString());
await page
.locator('form')
.filter({ hasText: 'Organization Billable' })
@@ -54,7 +56,9 @@ test('test that organization billable rate can be updated with all existing time
.click();
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time entries' }).click(),
page
.getByRole('button', { name: 'Yes, update existing time entries' })
.click(),
page.waitForRequest(
async (request) =>
request.url().includes('/organizations/') &&
@@ -66,12 +70,15 @@ test('test that organization billable rate can be updated with all existing time
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate === newBillableRate * 100
(await response.json()).data.billable_rate ===
newBillableRate * 100
),
]);
});
test('test that organization format settings can be updated', async ({ page }) => {
test('test that organization format settings can be updated', async ({
page,
}) => {
await goToOrganizationSettings(page);
// Test number format
@@ -106,7 +113,8 @@ test('test that organization format settings can be updated', async ({ page }) =
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.currency_format === 'iso-code-after-with-space'
(await response.json()).data.currency_format ===
'iso-code-after-with-space'
),
]);
@@ -124,7 +132,8 @@ test('test that organization format settings can be updated', async ({ page }) =
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.date_format === 'slash-separated-dd-mm-yyyy'
(await response.json()).data.date_format ===
'slash-separated-dd-mm-yyyy'
),
]);
@@ -160,14 +169,19 @@ test('test that organization format settings can be updated', async ({ page }) =
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.interval_format === 'hours-minutes-colon-separated'
(await response.json()).data.interval_format ===
'hours-minutes-colon-separated'
),
]);
});
test('test that format settings are reflected in the dashboard', async ({ page }) => {
test('test that format settings are reflected in the dashboard', async ({
page,
}) => {
// check that 0h 00min is displayed
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible();
await expect(
page.getByText('0h 00min', { exact: true }).nth(0)
).toBeVisible();
// First set the format settings
await goToOrganizationSettings(page);
@@ -199,8 +213,10 @@ test('test that format settings are reflected in the dashboard', async ({ page }
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.interval_format === 'hours-minutes-colon-separated' &&
(await response.json()).data.currency_format === 'symbol-after' &&
(await response.json()).data.interval_format ===
'hours-minutes-colon-separated' &&
(await response.json()).data.currency_format ===
'symbol-after' &&
(await response.json()).data.number_format === 'comma-point'
),
]);
@@ -216,12 +232,16 @@ test('test that format settings are reflected in the dashboard', async ({ page }
// check that 00:00 is displayed
await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible();
// check that 0h 00min is not displayed
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).not.toBeVisible();
await expect(
page.getByText('0h 00min', { exact: true }).nth(0)
).not.toBeVisible();
// check that the current date is displayed in the dd/mm/yyyy format on the time page
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(
page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)
page
.getByText(new Date().toLocaleDateString('en-GB'), { exact: true })
.nth(0)
).toBeVisible();
});

View File

@@ -1,32 +1,34 @@
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import {test, expect} from '../playwright/fixtures';
import {PLAYWRIGHT_BASE_URL} from '../playwright/config';
test('test that user name can be updated', async ({ page }) => {
test('test that user name can be updated', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await page.getByLabel('Name', { exact: true }).fill('NEW NAME');
await page.getByLabel('Name', {exact: true} ).fill('NEW NAME');
await Promise.all([
page.getByRole('button', { name: 'Save' }).first().click(),
page.getByRole('button', {name: 'Save'}).first().click(),
page.waitForResponse('**/user/profile-information'),
]);
await page.reload();
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
await expect(page.getByLabel('Name', {exact: true})).toHaveValue('NEW NAME');
});
test.skip('test that user email can be updated', async ({ page }) => {
test.skip('test that user email can be updated', async ({page}) => {
// this does not work because of email verification currently
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
const emailId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByRole('button', {name: 'Save'}).first().click();
await page.reload();
await expect(page.getByLabel('Email')).toHaveValue(`newemail+${emailId}@test.com`);
await expect(page.getByLabel('Email')).toHaveValue(
`newemail+${emailId}@test.com`
);
});
async function createNewApiToken(page) {
await page.getByLabel('API Key Name').fill('NEW API KEY');
await Promise.all([
page.getByRole('button', { name: 'Create API Key' }).click(),
page.waitForResponse('**/users/me/api-tokens'),
page.getByRole('button', {name: 'Create API Key'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.locator('body')).toContainText('API Token created successfully');
@@ -34,37 +36,34 @@ async function createNewApiToken(page) {
await expect(page.locator('body')).toContainText('NEW API KEY');
}
test('test that user can create an API key', async ({ page }) => {
test('test that user can create an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
});
test('test that user can delete an API key', async ({ page }) => {
test('test that user can delete an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Delete API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText(
'Are you sure you would like to delete this API token?'
);
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to delete this API token?');
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),
page.waitForResponse('**/users/me/api-tokens'),
page.getByRole('dialog').getByRole('button', {name: 'Delete'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.locator('body')).not.toContainText('NEW API KEY');
});
test('test that user can revoke an API key', async ({ page }) => {
test('test that user can revoke an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Revoke API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText(
'Are you sure you would like to revoke this API token?'
);
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to revoke this API token?');
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Revoke' }).click(),
page.waitForResponse('**/users/me/api-tokens'),
page.getByRole('dialog').getByRole('button', {name: 'Revoke'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.getByRole('button', { name: 'Revoke' })).toBeHidden();
await expect(page.getByRole('button', {name: 'Revoke'})).toBeHidden();
await expect(page.locator('body')).toContainText('NEW API KEY');
await expect(page.locator('body')).toContainText('Revoked');
});

View File

@@ -12,7 +12,8 @@ async function goToProjectsOverview(page: Page) {
test('test that updating project member billable rate works for existing time entries', async ({
page,
}) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
@@ -35,7 +36,9 @@ test('test that updating project member billable rate works for existing time en
.first()
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
await page
.getByRole('menuitem', { name: 'Edit Project Member' })
.click();
await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Project Member' }).click();
@@ -52,7 +55,8 @@ test('test that updating project member billable rate works for existing time en
response.url().includes('/project-members/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate === newBillableRate * 100
(await response.json()).data.billable_rate ===
newBillableRate * 100
),
]);
await expect(

View File

@@ -9,8 +9,11 @@ async function goToProjectsOverview(page: Page) {
}
// Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
test('test that creating and deleting a new project via the modal works', async ({
page,
}) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
@@ -28,10 +31,16 @@ test('test that creating and deleting a new project via the modal works', async
),
]);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
await expect(page.getByTestId('project_table')).toContainText(
newProjectName
);
const moreButton = page.locator(
"[aria-label='Actions for Project " + newProjectName + "']"
);
moreButton.click();
const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
const deleteButton = page.locator(
"[aria-label='Delete Project " + newProjectName + "']"
);
await Promise.all([
deleteButton.click(),
@@ -42,11 +51,14 @@ test('test that creating and deleting a new project via the modal works', async
response.status() === 204
),
]);
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
await expect(page.getByTestId('project_table')).not.toContainText(
newProjectName
);
});
test('test that archiving and unarchiving projects works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
@@ -75,8 +87,11 @@ test('test that archiving and unarchiving projects works', async ({ page }) => {
]);
});
test('test that updating billable rate works with existing time entries', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
test('test that updating billable rate works with existing time entries', async ({
page,
}) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
@@ -89,11 +104,15 @@ test('test that updating billable rate works with existing time entries', async
await page.getByRole('menuitem').getByText('Edit').first().click();
await page.getByText('Non-Billable').click();
await page.getByText('Custom Rate').click();
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
await page
.getByPlaceholder('Billable Rate')
.fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Project' }).click();
await Promise.all([
page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
page
.locator('button').filter({ hasText: 'Yes, update existing time' })
.click(),
page.waitForRequest(
async (request) =>
request.url().includes('/projects/') &&
@@ -105,7 +124,8 @@ test('test that updating billable rate works with existing time entries', async
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate === newBillableRate * 100
(await response.json()).data.billable_rate ===
newBillableRate * 100
),
]);
await expect(

View File

@@ -2,6 +2,8 @@ import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
}
@@ -29,10 +31,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Time entry for ${projectName}`);
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
@@ -44,9 +43,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
// Submit the time entry
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
page.waitForResponse(response => response.url().includes('/time-entries') && response.status() === 201)
]);
}
@@ -55,10 +52,7 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Time entry with tag ${tagName}`);
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
// Add tag
await page.getByRole('button', { name: 'Tags' }).click();
@@ -75,19 +69,12 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createTimeEntryWithBillableStatus(
page: Page,
isBillable: boolean,
duration: string
) {
async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean, duration: string) {
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
// Set billable status
await page.getByRole('button', { name: 'Non-Billable' }).click();
@@ -116,22 +103,19 @@ test('test that project filtering works in reporting', async ({ page }) => {
// Go to reporting and filter by project1
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByRole('dialog').getByText(project1).click();
await page.getByText(project1).click();
await Promise.all([
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
),
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
]);
await page.waitForLoadState('networkidle');
// Verify only project1 time entries are shown
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
await expect(page.getByText(project1)).toBeVisible();
await expect(page.getByText(project2)).not.toBeVisible();
});
test('test that tag filtering works in reporting', async ({ page }) => {
@@ -154,14 +138,11 @@ test('test that tag filtering works in reporting', async ({ page }) => {
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
),
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
]);
// Verify only time entries with tag1 are shown
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible();
});
test('test that billable status filtering works in reporting', async ({ page }) => {
@@ -179,16 +160,14 @@ test('test that billable status filtering works in reporting', async ({ page })
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
),
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
]);
await page.waitForLoadState('networkidle');
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible();
});
test('test that detailed view shows time entries correctly', async ({ page }) => {
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);

View File

@@ -7,7 +7,9 @@ async function goToTagsOverview(page: Page) {
}
// Create new project via modal
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
test('test that creating and deleting a new client via the modal works', async ({
page,
}) => {
const newTagName = 'New Tag ' + Math.floor(1 + Math.random() * 10000);
await goToTagsOverview(page);
await page.getByRole('button', { name: 'Create Tag' }).click();
@@ -25,9 +27,13 @@ test('test that creating and deleting a new client via the modal works', async (
]);
await expect(page.getByTestId('tag_table')).toContainText(newTagName);
const moreButton = page.locator("[aria-label='Actions for Tag " + newTagName + "']");
const moreButton = page.locator(
"[aria-label='Actions for Tag " + newTagName + "']"
);
moreButton.click();
const deleteButton = page.locator("[aria-label='Delete Tag " + newTagName + "']");
const deleteButton = page.locator(
"[aria-label='Delete Tag " + newTagName + "']"
);
await Promise.all([
deleteButton.click(),

View File

@@ -7,8 +7,11 @@ async function goToProjectsOverview(page: Page) {
}
// Create new project via modal
test('test that creating and deleting a new tag in a new project works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
test('test that creating and deleting a new tag in a new project works', async ({
page,
}) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
@@ -26,7 +29,9 @@ test('test that creating and deleting a new tag in a new project works', async (
),
]);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
await expect(page.getByTestId('project_table')).toContainText(
newProjectName
);
await page.getByText(newProjectName).click();
@@ -50,9 +55,13 @@ test('test that creating and deleting a new tag in a new project works', async (
await expect(page.getByTestId('task_table')).toContainText(newTaskName);
const taskMoreButton = page.locator("[aria-label='Actions for Task " + newTaskName + "']");
const taskMoreButton = page.locator(
"[aria-label='Actions for Task " + newTaskName + "']"
);
taskMoreButton.click();
const taskDeleteButton = page.locator("[aria-label='Delete Task " + newTaskName + "']");
const taskDeleteButton = page.locator(
"[aria-label='Delete Task " + newTaskName + "']"
);
await Promise.all([
taskDeleteButton.click(),
@@ -67,9 +76,13 @@ test('test that creating and deleting a new tag in a new project works', async (
await goToProjectsOverview(page);
const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
const moreButton = page.locator(
"[aria-label='Actions for Project " + newProjectName + "']"
);
moreButton.click();
const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
const deleteButton = page.locator(
"[aria-label='Delete Project " + newProjectName + "']"
);
await Promise.all([
deleteButton.click(),
@@ -80,11 +93,14 @@ test('test that creating and deleting a new tag in a new project works', async (
response.status() === 204
),
]);
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
await expect(page.getByTestId('project_table')).not.toContainText(
newProjectName
);
});
test('test that archiving and unarchiving tasks works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newTaskName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);

View File

@@ -25,7 +25,9 @@ async function createEmptyTimeEntry(page: Page) {
startOrStopTimerWithButton(page),
assertThatTimerIsStopped(page),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 200
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
),
]);
}
@@ -36,7 +38,9 @@ test('test that starting and stopping an empty time entry shows a new time entry
await Promise.all([
goToTimeOverview(page),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 200
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
),
]);
await page.waitForTimeout(100);
@@ -52,7 +56,9 @@ test('test that starting and stopping an empty time entry shows a new time entry
// Test that description update works
async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) {
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(/bg-accent-300\/70/);
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
/bg-accent-300\/70/
);
}
test('test that updating a description of a time entry in the overview works on blur', async ({
@@ -65,14 +71,17 @@ test('test that updating a description of a time entry in the overview works on
await assertThatTimeEntryRowIsStopped(newTimeEntry);
const newDescription = Math.floor(Math.random() * 1000000).toString();
const descriptionElement = newTimeEntry.getByTestId('time_entry_description');
const descriptionElement = newTimeEntry.getByTestId(
'time_entry_description'
);
await descriptionElement.fill(newDescription);
await Promise.all([
descriptionElement.press('Tab'),
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(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 &&
@@ -81,7 +90,8 @@ test('test that updating a description of a time entry in the overview works on
(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([])
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
]);
@@ -97,14 +107,17 @@ test('test that updating a description of a time entry in the overview works on
const newTimeEntry = timeEntryRows.first();
await assertThatTimeEntryRowIsStopped(newTimeEntry);
const newDescription = Math.floor(Math.random() * 1000000).toString();
const descriptionElement = newTimeEntry.getByTestId('time_entry_description');
const descriptionElement = newTimeEntry.getByTestId(
'time_entry_description'
);
await descriptionElement.fill(newDescription);
await Promise.all([
descriptionElement.press('Enter'),
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(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 &&
@@ -113,13 +126,16 @@ test('test that updating a description of a time entry in the overview works on
(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([])
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
]);
});
test('test that adding a new tag to an existing time entry works', async ({ page }) => {
test('test that adding a new tag to an existing time entry works', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
@@ -136,7 +152,8 @@ test('test that adding a new tag to an existing time entry works', async ({ page
page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.name === newTagName
);
}),
@@ -146,7 +163,8 @@ test('test that adding a new tag to an existing time entry works', async ({ page
await page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(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 &&
@@ -169,14 +187,17 @@ test('test that updating a the start of an existing time entry in the overview w
const newTimeEntry = timeEntryRows.first();
await assertThatTimeEntryRowIsStopped(newTimeEntry);
await page.waitForTimeout(1500);
const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector');
const timeEntryRangeElement = newTimeEntry.getByTestId(
'time_entry_range_selector'
);
await timeEntryRangeElement.click();
await page.getByTestId('time_entry_range_start').first().fill('1');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
// TODO! Actually check the value
(await response.json()).data.start !== null &&
@@ -187,7 +208,9 @@ test('test that updating a the start of an existing time entry in the overview w
]);
});
test('test that updating a the duration in the overview works on blur', async ({ page }) => {
test('test that updating a the duration in the overview works on blur', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
@@ -202,7 +225,8 @@ test('test that updating a the duration in the overview works on blur', async ({
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
// TODO! Actually check the value
(await response.json()).data.start !== null &&
@@ -216,7 +240,9 @@ test('test that updating a the duration in the overview works on blur', async ({
});
// Test that start stop button stops running timer
test('test that starting a time entry from the overview works', async ({ page }) => {
test('test that starting a time entry from the overview works', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
@@ -229,7 +255,8 @@ test('test that starting a time entry from the overview works', async ({ page })
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(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
@@ -245,7 +272,8 @@ test('test that starting a time entry from the overview works', async ({ page })
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(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
@@ -256,7 +284,9 @@ test('test that starting a time entry from the overview works', async ({ page })
]);
});
test('test that deleting a time entry from the overview works', async ({ page }) => {
test('test that deleting a time entry from the overview works', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
@@ -272,12 +302,16 @@ test('test that deleting a time entry from the overview works', async ({ page })
await expect(timeEntryRows).toHaveCount(0);
});
test.skip('test that load more works when the end of page is reached', async ({ page }) => {
test.skip('test that load more works when the end of page is reached', async ({
page,
}) => {
// this test is flaky when you do not need to scroll
await Promise.all([
goToTimeOverview(page),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 200
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
),
]);
@@ -288,14 +322,18 @@ test.skip('test that load more works when the end of page is reached', async ({
return (
response.status() === 200 &&
response.url().includes('before') &&
(await response.headerValue('Content-Type')) === 'application/json' &&
JSON.stringify((await response.json()).data) === JSON.stringify([])
(await response.headerValue('Content-Type')) ===
'application/json' &&
JSON.stringify((await response.json()).data) ===
JSON.stringify([])
);
}),
]);
// assert that "All time entries are loaded!" is visible on page
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
await expect(page.locator('body')).toHaveText(
/All time entries are loaded!/
);
});
// TODO: Test that updating the time entry start / end times works while it is running

View File

@@ -24,15 +24,22 @@ test('test that starting and stopping a timer without description and project wo
assertThatTimerHasStarted(page),
]);
await page.waitForTimeout(1500);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that starting and stopping a timer with a description works', async ({ page }) => {
test('test that starting and stopping a timer with a description works', async ({
page,
}) => {
await goToDashboard(page);
// TODO: Fix flakyness by disabling description input field until timer is loaded
await page.waitForTimeout(500);
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
await page
.getByTestId('time_entry_description')
.fill('New Time Entry Description');
await Promise.all([
newTimeEntryResponse(page, {
description: 'New Time Entry Description',
@@ -55,29 +62,47 @@ test('test that starting the time entry starts the live timer and that it keeps
}) => {
await goToDashboard(page);
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
const beforeTimerValue = await page.getByTestId('time_entry_time').inputValue();
const beforeTimerValue = await page
.getByTestId('time_entry_time')
.inputValue();
await page.waitForTimeout(2000);
const afterWaitTimeValue = await page.getByTestId('time_entry_time').inputValue();
const afterWaitTimeValue = await page
.getByTestId('time_entry_time')
.inputValue();
expect(afterWaitTimeValue).not.toEqual(beforeTimerValue);
await page.reload();
await page.waitForTimeout(500);
const afterReloadTimerValue = await page.getByTestId('time_entry_time').inputValue();
const afterReloadTimerValue = await page
.getByTestId('time_entry_time')
.inputValue();
await page.waitForTimeout(2000);
const afterReloadAfterWaitTimerValue = await page.getByTestId('time_entry_time').inputValue();
const afterReloadAfterWaitTimerValue = await page
.getByTestId('time_entry_time')
.inputValue();
expect(afterReloadTimerValue).not.toEqual(afterReloadAfterWaitTimerValue);
});
test('test that starting and updating the description while running works', async ({ page }) => {
test('test that starting and updating the description while running works', async ({
page,
}) => {
await goToDashboard(page);
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
await page
.getByTestId('time_entry_description')
.fill('New Time Entry Description');
await Promise.all([
newTimeEntryResponse(page, {
@@ -96,7 +121,9 @@ test('test that starting and updating the description while running works', asyn
await assertThatTimerIsStopped(page);
});
test('test that starting and updating the time while running works', async ({ page }) => {
test('test that starting and updating the time while running works', async ({
page,
}) => {
await goToDashboard(page);
const [createResponse] = await Promise.all([
newTimeEntryResponse(page),
@@ -111,16 +138,19 @@ test('test that starting and updating the time while running works', async ({ pa
return (
response.url().includes('/time-entries') &&
response.status() === 200 &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(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.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.user_id !== null &&
JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
page.getByTestId('time_entry_time').press('Enter'),
@@ -128,11 +158,16 @@ test('test that starting and updating the time while running works', async ({ pa
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
await page.waitForTimeout(500);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that entering a human readable time starts the timer on blur', async ({ page }) => {
test('test that entering a human readable time starts the timer on blur', async ({
page,
}) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('20min');
await Promise.all([
@@ -142,13 +177,18 @@ test('test that entering a human readable time starts the timer on blur', async
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/);
await assertThatTimerHasStarted(page);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
});
test('test that entering a number in the time range starts the timer on blur', async ({ page }) => {
test('test that entering a number in the time range starts the timer on blur', async ({
page,
}) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('5');
await Promise.all([
@@ -158,7 +198,10 @@ test('test that entering a number in the time range starts the timer on blur', a
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:05:/);
await assertThatTimerHasStarted(page);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
@@ -176,7 +219,10 @@ test('test that entering a value with the format hh:mm in the time range starts
await expect(page.getByTestId('time_entry_time')).toHaveValue(/12:30:/);
await assertThatTimerHasStarted(page);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
@@ -193,7 +239,9 @@ test('test that entering a random value in the time range does not start the tim
);
});
test('test that entering a time starts the timer on enter', async ({ page }) => {
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 Promise.all([
@@ -201,7 +249,10 @@ test('test that entering a time starts the timer on enter', async ({ page }) =>
page.getByTestId('time_entry_time').press('Enter'),
]);
await assertThatTimerHasStarted(page);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
@@ -222,10 +273,15 @@ test('test that adding a new tag works', async ({ page }) => {
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
});
test('test that adding a new tag when the timer is running', async ({ page }) => {
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 Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
await page.getByTestId('tag_dropdown').click();
await page.getByText('Create new tag').click();

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