Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
4a7f587b0a Add postgres version matrix to phpunit tests 2025-06-04 21:38:36 +02:00
516 changed files with 7879 additions and 10099 deletions

View File

@@ -80,7 +80,8 @@ GOTENBERG_URL=http://gotenberg:3000
# Local setup # Local setup
NGINX_HOST_NAME=solidtime.test NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing 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_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage #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) We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
-->
## 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

View File

@@ -1,216 +0,0 @@
on:
push:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-onpremise.yml'
- 'docker/prod/**'
workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env:
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
name: Build - On Premise
jobs:
build:
strategy:
matrix:
include:
- runs-on: "ubuntu-24.04-arm"
platform: "linux/arm64"
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: release-build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: release-version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production"
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies"
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install npm dependencies"
run: npm ci
- name: "Build"
run: npm run build
- name: "Prepare"
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Build and push by digest"
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: "Export digest"
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest"
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
timeout-minutes: 90
needs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_REPO }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Create manifest list and push"
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: "Inspect image"
run: |
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}

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, "tabWidth": 4,
"singleQuote": true, "singleQuote": true,
"bracketSameLine": true, "bracketSameLine": true,
"quoteProps": "preserve", "quoteProps": "preserve"
"printWidth": 100
} }

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 ## 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. 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. * Create a newly registered user.
* *
* @param array<string, mixed> $input * @param array<string, string> $input
* *
* @throws ValidationException * @throws ValidationException
*/ */

View File

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

View File

@@ -57,7 +57,7 @@ class AddOrganizationMember implements AddsTeamMembers
*/ */
protected function rules(): array protected function rules(): array
{ {
return [ return array_filter([
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
@@ -75,7 +75,7 @@ class AddOrganizationMember implements AddsTeamMembers
Role::Employee->value, 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')) ->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes(); ->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') $schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update')) ->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily(); ->twiceDaily();
@@ -32,7 +28,7 @@ class Kernel extends ConsoleKernel
$schedule->command('self-host:database-consistency') $schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency')) ->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->everySixHours(); ->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

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

View File

@@ -41,7 +41,9 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
return null; return null;
} }
$collectingType = $this->openApiTransformer->transform($collectingClassType); if (! ($collectingType = $this->openApiTransformer->transform($collectingClassType))) {
return null;
}
$newType = new OpenApiObjectType; $newType = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType)); $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\Action;
use Filament\Tables\Actions\BulkAction; use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction; use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -76,8 +75,7 @@ class FailedJobResource extends Resource
->filters([]) ->filters([])
->bulkActions([ ->bulkActions([
BulkAction::make('retry') BulkAction::make('retry')
->icon('heroicon-o-arrow-path') ->label('Retry')
->label('Retry selected')
->requiresConfirmation() ->requiresConfirmation()
->action(function (Collection $records): void { ->action(function (Collection $records): void {
/** @var FailedJob $record */ /** @var FailedJob $record */
@@ -89,13 +87,11 @@ class FailedJobResource extends Resource
->success() ->success()
->send(); ->send();
}), }),
DeleteBulkAction::make(),
]) ])
->actions([ ->actions([
DeleteAction::make(), DeleteAction::make('Delete'),
ViewAction::make(), ViewAction::make('View'),
Action::make('retry') Action::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry') ->label('Retry')
->requiresConfirmation() ->requiresConfirmation()
->action(function (FailedJob $record): void { ->action(function (FailedJob $record): void {
@@ -113,6 +109,7 @@ class FailedJobResource extends Resource
return [ return [
'index' => ListFailedJobs::route('/'), 'index' => ListFailedJobs::route('/'),
'view' => ViewFailedJobs::route('/{record}'), 'view' => ViewFailedJobs::route('/{record}'),
]; ];
} }
} }

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages; use App\Filament\Resources\TokenResource\Pages;
use App\Models\Passport\Client;
use App\Models\Passport\Token; use App\Models\Passport\Token;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Form; use Filament\Forms\Form;
@@ -39,7 +40,7 @@ class TokenResource extends Resource
->label('Name') ->label('Name')
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('owner_id') Forms\Components\Select::make('user_id')
->label('User') ->label('User')
->relationship(name: 'user', titleAttribute: 'name') ->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name']) ->searchable(['name'])
@@ -78,12 +79,10 @@ class TokenResource extends Resource
Tables\Columns\TextColumn::make('client.name') Tables\Columns\TextColumn::make('client.name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\IconColumn::make('personal_access_client') Tables\Columns\IconColumn::make('client.personal_access_client')
->state(function (Token $token): bool {
return in_array('personal_access', $token->client->grant_types ?? [], true);
})
->boolean() ->boolean()
->label('API token?'), ->label('API token?')
->sortable(),
Tables\Columns\IconColumn::make('revoked') Tables\Columns\IconColumn::make('revoked')
->boolean() ->boolean()
->label('Revoked?') ->label('Revoked?')
@@ -105,11 +104,17 @@ class TokenResource extends Resource
->queries( ->queries(
true: function (Builder $query) { true: function (Builder $query) {
/** @var Builder<Token> $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) { false: function (Builder $query) {
/** @var Builder<Token> $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) { blank: function (Builder $query) {
/** @var Builder<Token> $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\Requests\V1\ApiToken\ApiTokenStoreRequest;
use App\Http\Resources\V1\ApiToken\ApiTokenCollection; use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource; use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
use App\Models\Passport\Client;
use App\Models\Passport\Token; use App\Models\Passport\Token;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
class ApiTokenController extends Controller class ApiTokenController extends Controller
{ {
@@ -31,10 +28,7 @@ class ApiTokenController extends Controller
$user = $this->user(); $user = $this->user();
$tokens = $user->tokens() $tokens = $user->tokens()
->whereHas('client', function (Builder $query): void { ->where('client_id', '=', config('passport.personal_access_client.id'))
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
})
->get(); ->get();
return new ApiTokenCollection($tokens); return new ApiTokenCollection($tokens);
@@ -54,21 +48,15 @@ class ApiTokenController extends Controller
{ {
$user = $this->user(); $user = $this->user();
try { if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
$token = $user->createToken($request->getName(), ['*']); throw new PersonalAccessClientIsNotConfiguredException;
/** @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;
} }
$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(); $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()) { if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user'); 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'); throw new AuthorizationException('API token is not a personal access token');
} }
@@ -106,10 +97,13 @@ class ApiTokenController extends Controller
{ {
$user = $this->user(); $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()) { if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user'); 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'); 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 class ChartController extends Controller
{ {
/** /**
* Get chart data for the weekly project overview.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId weeklyProjectOverview * @operationId weeklyProjectOverview
@@ -33,8 +31,6 @@ class ChartController extends Controller
} }
/** /**
* Get chart data for the latest tasks.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId latestTasks * @operationId latestTasks
@@ -52,8 +48,6 @@ class ChartController extends Controller
} }
/** /**
* Get chart data for the last seven days.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId lastSevenDays * @operationId lastSevenDays
@@ -71,8 +65,6 @@ class ChartController extends Controller
} }
/** /**
* Get chart data for the latest team activity.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId latestTeamActivity * @operationId latestTeamActivity
@@ -89,8 +81,6 @@ class ChartController extends Controller
} }
/** /**
* Get chart data for daily tracked hours.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId dailyTrackedHours * @operationId dailyTrackedHours
@@ -108,8 +98,6 @@ class ChartController extends Controller
} }
/** /**
* Get chart data for total weekly time.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId totalWeeklyTime * @operationId totalWeeklyTime
@@ -127,8 +115,6 @@ class ChartController extends Controller
} }
/** /**
* Get chart data for total weekly billable time.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId totalWeeklyBillableTime * @operationId totalWeeklyBillableTime
@@ -146,8 +132,6 @@ class ChartController extends Controller
} }
/** /**
* Get chart data for total weekly billable amount.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId totalWeeklyBillableAmount * @operationId totalWeeklyBillableAmount
@@ -170,8 +154,6 @@ class ChartController extends Controller
} }
/** /**
* Get chart data for weekly history.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId weeklyHistory * @operationId weeklyHistory

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest; use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
@@ -51,7 +50,6 @@ class InvitationController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException * @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws InvitationForTheEmailAlreadyExistsApiException
* *
* @operationId invite * @operationId invite
*/ */

View File

@@ -10,14 +10,12 @@ use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember; use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException; use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberDestroyRequest;
use App\Http\Requests\V1\Member\MemberIndexRequest; use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberMergeIntoRequest; use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest; use App\Http\Requests\V1\Member\MemberUpdateRequest;
@@ -102,13 +100,11 @@ class MemberController extends Controller
* *
* @operationId removeMember * @operationId removeMember
*/ */
public function destroy(MemberDestroyRequest $request, Organization $organization, Member $member, MemberService $memberService): JsonResponse public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{ {
$this->checkPermission($organization, 'members:delete', $member); $this->checkPermission($organization, 'members:delete', $member);
$deleteRelated = $request->getDeleteRelated(); $memberService->removeMember($member, $organization);
$memberService->removeMember($member, $organization, $deleteRelated);
return response() return response()
->json(null, 204); ->json(null, 204);
@@ -174,7 +170,6 @@ class MemberController extends Controller
* @throws UserNotPlaceholderApiException * @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException * @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException * @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
* @throws InvitationForTheEmailAlreadyExistsApiException
* *
* @operationId invitePlaceholder * @operationId invitePlaceholder
*/ */

View File

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

View File

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

View File

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

View File

@@ -43,10 +43,7 @@ class Controller extends BaseController
/** @var Member|null $member */ /** @var Member|null $member */
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first(); $member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) { if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization', [ Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
'user' => $user->getKey(),
'organization' => $organization->getKey(),
]);
throw new AuthorizationException; throw new AuthorizationException;
} }

View File

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

View File

@@ -41,8 +41,7 @@ class HandleInertiaRequests extends Middleware
{ {
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing'); $hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services');
/** @var BillingContract $billing */ /** @var BillingContract $billing */
$billing = app(BillingContract::class); $billing = app(BillingContract::class);
@@ -51,8 +50,7 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [ return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing, 'has_invoicing_extension' => $hasInvoicing,
'has_services_extension' => $hasServices, 'billing' => $billing !== null && $currentOrganization !== null ? [
'billing' => $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization), 'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization), 'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(), 'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),

View File

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

View File

@@ -7,8 +7,11 @@ namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role; use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
@@ -26,6 +29,10 @@ class InvitationStoreRequest extends BaseFormRequest
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.invitation_already_exists'),
], ],
'role' => [ 'role' => [
'required', 'required',

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* @property Organization $organization
*/
class MemberDestroyRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'delete_related' => [
'string',
'in:true,false',
],
];
}
public function getDeleteRelated(): bool
{
return $this->input('delete_related', 'false') === 'true';
}
}

View File

@@ -6,7 +6,6 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
@@ -129,18 +128,6 @@ class ReportStoreRequest extends BaseFormRequest
'nullable', 'nullable',
'timezone:all', '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')); 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\ExportFormat;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -165,18 +164,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
'string', 'string',
'in:true,false', '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')); 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -147,18 +146,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
'string', 'string',
'in:true,false', '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; 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TimeEntryRoundingType;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -134,18 +133,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string', 'string',
'in:true,false', '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')); 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -12,10 +11,8 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -26,7 +23,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
/** /**
* Get the validation rules that apply to the request. * 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 public function rules(): array
{ {
@@ -139,18 +136,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string', 'string',
'in:true,false', '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; 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 App\Models\Project;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
private bool $showBillableRates; private bool $showBillableRates;
/**
* @param LengthAwarePaginator<Project> $resource
*/
public function __construct($resource, bool $showBillableRates) public function __construct($resource, bool $showBillableRates)
{ {
parent::__construct($resource); parent::__construct($resource);

View File

@@ -58,10 +58,6 @@ class DetailedReportResource extends BaseResource
'tag_ids' => $this->resource->properties->tagIds?->toArray(), 'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */ /** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(), '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 */ /** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), '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 $event
* @property string $auditable_type * @property string $auditable_type
* @property string $auditable_id * @property string $auditable_id
* @property array<string, mixed>|null $old_values * @property array|null $old_values
* @property array<string, mixed>|null $new_values * @property array|null $new_values
* @property string|null $url * @property string|null $url
* @property string|null $ip_address * @property string|null $ip_address
* @property string|null $user_agent * @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 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 public function projects(): HasMany
{ {

View File

@@ -25,8 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Organization $organization * @property-read Organization $organization
* @property-read User $user * @property-read User $user
* @property-read Collection<int, ProjectMember> $projectMembers * @property-read Collection<ProjectMember> $projectMembers
* @property-read Collection<int, TimeEntry> $timeEntries * @property-read Collection<TimeEntry> $timeEntries
* *
* @method static MemberFactory factory() * @method static MemberFactory factory()
*/ */
@@ -47,7 +47,7 @@ class Member extends JetstreamMembership implements AuditableContract
protected $table = 'members'; protected $table = 'members';
/** /**
* @return BelongsTo<User, $this> * @return BelongsTo<User, Member>
*/ */
public function user(): BelongsTo 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 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 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 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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamCreated;
@@ -48,7 +47,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property IntervalFormat $interval_format * @property IntervalFormat $interval_format
* @property TimeFormat $time_format * @property TimeFormat $time_format
* *
* @method HasMany<OrganizationInvitation, $this> teamInvitations() * @method HasMany<OrganizationInvitation> teamInvitations()
* @method static OrganizationFactory factory() * @method static OrganizationFactory factory()
*/ */
class Organization extends JetstreamTeam implements AuditableContract class Organization extends JetstreamTeam implements AuditableContract
@@ -80,7 +79,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
* @var list<string> * @var array<int, string>
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name',
@@ -126,7 +125,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/** /**
* Get all the users that belong to the team. * Get all the users that belong to the team.
* *
* @return BelongsToMany<User, $this, Pivot, 'membership'> * @return BelongsToMany<User>
*/ */
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
@@ -143,7 +142,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/** /**
* Get the owner of the team. * Get the owner of the team.
* *
* @return BelongsTo<User, $this> * @return BelongsTo<User, Organization>
*/ */
public function owner(): BelongsTo 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 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 public function realUsers(): BelongsToMany
{ {

View File

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

View File

@@ -4,26 +4,6 @@ declare(strict_types=1);
namespace App\Models\Passport; namespace App\Models\Passport;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Passport\AuthCode as PassportAuthCode; use Laravel\Passport\AuthCode as PassportAuthCode;
/** class AuthCode extends 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');
}
}

View File

@@ -5,36 +5,22 @@ declare(strict_types=1);
namespace App\Models\Passport; namespace App\Models\Passport;
use Database\Factories\Passport\ClientFactory; use Database\Factories\Passport\ClientFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Carbon;
use Laravel\Passport\Client as PassportClient; use Laravel\Passport\Client as PassportClient;
/** /**
* @property string $id * @property string $id
* @property string|null $owner_id * @property string|null $user_id
* @property string|null $owner_type
* @property string $name * @property string $name
* @property string|null $secret * @property string|null $secret
* @property string|null $provider * @property string|null $provider
* @property array<string> $grant_types * @property string $redirect
* @property array<string> $redirect_uris * @property bool $personal_access_client
* @property Carbon|null $created_at * @property bool $password_client
* @property Carbon|null $updated_at
* @property bool $revoked * @property bool $revoked
*/ */
class Client extends PassportClient class Client extends PassportClient
{ {
/** @use HasFactory<ClientFactory> */ /** @use HasFactory<ClientFactory> */
use HasFactory; 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; namespace App\Models\Passport;
use App\Models\User;
use Database\Factories\Passport\TokenFactory; use Database\Factories\Passport\TokenFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -19,15 +17,9 @@ use Laravel\Passport\Token as PassportToken;
* @property null|string $name * @property null|string $name
* @property array<string> $scopes * @property array<string> $scopes
* @property bool $revoked * @property bool $revoked
* @property Carbon|null $reminder_sent_at
* @property Carbon|null $expired_info_sent_at
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property Carbon|null $expires_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 class Token extends PassportToken
{ {
@@ -37,60 +29,10 @@ class Token extends PassportToken
/** /**
* Get the client that the token belongs to. * Get the client that the token belongs to.
* *
* @return BelongsTo<Client, $this> * @return BelongsTo<Client, Token>
*/ */
// @phpstan-ignore method.childReturnType
public function client(): BelongsTo public function client(): BelongsTo
{ {
return $this->belongsTo(Client::class, 'client_id', 'id'); 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 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 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 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 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 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 public function project(): BelongsTo
{ {
@@ -58,7 +58,7 @@ class ProjectMember extends Model implements AuditableContract
/** /**
* @deprecated Use member relationship instead * @deprecated Use member relationship instead
* *
* @return BelongsTo<User, $this> * @return BelongsTo<User, ProjectMember>
*/ */
public function user(): BelongsTo 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 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 public function organization(): BelongsTo
{ {

View File

@@ -22,7 +22,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
* @property string $organization_id * @property string $organization_id
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Collection<int, TimeEntry> $timeEntries * @property-read Collection<TimeEntry> $timeEntries
* @property-read Organization $organization * @property-read Organization $organization
* *
* @method static TagFactory factory() * @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 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 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 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 public function timeEntries(): HasMany
{ {

View File

@@ -28,7 +28,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property Carbon|null $end * @property Carbon|null $end
* @property int|null $billable_rate Billable rate per hour in cents * @property int|null $billable_rate Billable rate per hour in cents
* @property bool $billable * @property bool $billable
* @property array<string> $tags * @property array $tags
* @property string $user_id * @property string $user_id
* @property string $member_id * @property string $member_id
* @property bool $is_imported * @property bool $is_imported
@@ -45,7 +45,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property-read Client|null $client * @property-read Client|null $client
* @property string|null $task_id * @property string|null $task_id
* @property-read Task|null $task * @property-read Task|null $task
* @property-read Collection<int, Tag> $tagsRelation * @property-read Collection<Tag> $tagsRelation
* *
* @method Builder<TimeEntry> hasTag(Tag $tag) * @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory() * @method static TimeEntryFactory factory()
@@ -77,26 +77,6 @@ class TimeEntry extends Model implements AuditableContract
'still_active_email_sent_at' => 'datetime', '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) * The attributes that are computed. (f.e. for performance reasons)
* These attributes can be regenerated at any time. * 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 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 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 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 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 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. * 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 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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -28,7 +27,6 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams; use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode; use Laravel\Passport\AuthCode;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -54,13 +52,13 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Collection<int, TimeEntry> $timeEntries * @property Collection<int, TimeEntry> $timeEntries
* @property Member $membership * @property Member $membership
* *
* @method HasMany<Organization, $this> ownedTeams() * @method HasMany<Organization> ownedTeams()
* @method static UserFactory factory() * @method static UserFactory factory()
* @method static Builder<User> query() * @method static Builder<User> query()
* @method Builder<User> belongsToOrganization(Organization $organization) * @method Builder<User> belongsToOrganization(Organization $organization)
* @method Builder<User> active() * @method Builder<User> active()
*/ */
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail, OAuthenticatable class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail
{ {
use CustomAuditable; use CustomAuditable;
use HasApiTokens; use HasApiTokens;
@@ -77,7 +75,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
* @var list<string> * @var array<int, string>
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name',
@@ -88,7 +86,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
/** /**
* The attributes that should be hidden for serialization. * The attributes that should be hidden for serialization.
* *
* @var list<string> * @var array<int, string>
*/ */
protected $hidden = [ protected $hidden = [
'password', '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 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 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 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 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 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 public function authCodes(): HasMany
{ {
return $this->hasMany(AuthCode::class); 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 * @param Builder<User> $builder
*/ */

View File

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

View File

@@ -187,7 +187,6 @@ class JetstreamServiceProvider extends ServiceProvider
'members:invite-placeholder', 'members:invite-placeholder',
'members:make-placeholder', 'members:make-placeholder',
'members:merge-into', 'members:merge-into',
'members:delete',
'members:update', 'members:update',
'reports:view', 'reports:view',
'reports:create', 'reports:create',

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use Brick\Money\ISOCurrencyProvider;
use Brick\Money\Money; use Brick\Money\Money;
class CurrencyService class CurrencyService
@@ -375,12 +374,4 @@ class CurrencyService
return $currencyCode; 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\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
@@ -60,10 +59,6 @@ class ReportPropertiesDto implements Castable
*/ */
public ?Collection $taskIds = null; 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. * 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->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
$dto->weekStart = Weekday::from($data->weekStart); $dto->weekStart = Weekday::from($data->weekStart);
$dto->timezone = $data->timezone; $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; return $dto;
} }
/**
* @param ReportPropertiesDto $value
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string public function set(Model $model, string $key, mixed $value, array $attributes): string
{ {
if (! ($value instanceof ReportPropertiesDto)) { if (! ($value instanceof ReportPropertiesDto)) {
@@ -149,8 +143,6 @@ class ReportPropertiesDto implements Castable
'historyGroup' => $value->historyGroup->value, 'historyGroup' => $value->historyGroup->value,
'weekStart' => $value->weekStart->value, 'weekStart' => $value->weekStart->value,
'timezone' => $value->timezone, 'timezone' => $value->timezone,
'roundingType' => $value->roundingType?->value,
'roundingMinutes' => $value->roundingMinutes,
]; ];
$jsonString = json_encode($data); $jsonString = json_encode($data);

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Mail\OrganizationInvitationMail; use App\Mail\OrganizationInvitationMail;
use App\Models\Member; use App\Models\Member;
@@ -17,7 +16,7 @@ use Laravel\Jetstream\Events\InvitingTeamMember;
class InvitationService class InvitationService
{ {
/** /**
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException * @throws UserIsAlreadyMemberOfOrganizationApiException
*/ */
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{ {
@@ -29,13 +28,6 @@ class InvitationService
throw new UserIsAlreadyMemberOfOrganizationApiException; throw new UserIsAlreadyMemberOfOrganizationApiException;
} }
if (OrganizationInvitation::query()
->where('email', $email)
->whereBelongsTo($organization, 'organization')
->exists()) {
throw new InvitationForTheEmailAlreadyExistsApiException;
}
InvitingTeamMember::dispatch($organization, $email, $role->value); InvitingTeamMember::dispatch($organization, $email, $role->value);
$invitation = new OrganizationInvitation; $invitation = new OrganizationInvitation;

View File

@@ -45,9 +45,6 @@ class MemberService
$member->organization()->associate($organization); $member->organization()->associate($organization);
$member->role = $role->value; $member->role = $role->value;
$member->save(); $member->save();
$user->currentOrganization()->associate($organization);
$user->save();
}); });
if (! $asSuperAdmin) { if (! $asSuperAdmin) {
@@ -61,41 +58,19 @@ class MemberService
* @throws CanNotRemoveOwnerFromOrganization * @throws CanNotRemoveOwnerFromOrganization
* @throws EntityStillInUseApiException * @throws EntityStillInUseApiException
*/ */
public function removeMember(Member $member, Organization $organization, bool $withRelations = false): void public function removeMember(Member $member, Organization $organization): void
{ {
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) { if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization; throw new CanNotRemoveOwnerFromOrganization;
} }
$user = $member->user;
$isPlaceholder = $user->is_placeholder;
if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {
$user->currentTeam()->disassociate();
$user->save();
}
if ($withRelations) {
TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->delete();
ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->delete();
} else {
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
}
$member->delete(); $member->delete();
if ($isPlaceholder) {
$user->delete();
} else {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
MemberRemoved::dispatch($member, $organization); MemberRemoved::dispatch($member, $organization);
} }

View File

@@ -71,7 +71,7 @@ class PermissionStore
/** @var Role|null $roleObj */ /** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role); $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\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Client; use App\Models\Client;
use App\Models\Project; use App\Models\Project;
@@ -42,7 +41,7 @@ class TimeEntryAggregationService
* cost: int|null * 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; $fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
$group1Select = 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( $timeEntriesQuery->selectRaw(
($group1Select !== null ? $group1Select.' as group_1,' : ''). ($group1Select !== null ? $group1Select.' as group_1,' : '').
($group2Select !== null ? $group2Select.' as group_2,' : ''). ($group2Select !== null ? $group2Select.' as group_2,' : '').
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. ' round(sum(extract(epoch from (coalesce("end", now()) - start)))) 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)) * (coalesce(billable_rate, 0)::float/60/60)
)
) as cost'
); );
if ($groupBy !== null) { if ($groupBy !== null) {
$timeEntriesQuery->groupBy($groupBy); $timeEntriesQuery->groupBy($groupBy);
@@ -164,9 +164,9 @@ class TimeEntryAggregationService
* cost: int|null * 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 = []; $keysGroup1 = [];
$keysGroup2 = []; $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", "datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.12.2", "dedoc/scramble": "^0.12.2",
"filament/filament": "^3.2", "filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.4.0", "flowframe/laravel-trend": "^0.3.0",
"gotenberg/gotenberg-php": "^2.8", "gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^2.0.3", "inertiajs/inertia-laravel": "^1.0",
"korridor/laravel-computed-attributes": "^3.1", "korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1", "korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0", "korridor/laravel-model-validation-rules": "^3.0",
"laravel/framework": "^12.19.3", "laravel/framework": "^11.16.0",
"laravel/jetstream": "^5.0", "laravel/jetstream": "^5.0",
"laravel/octane": "^2.3", "laravel/octane": "^2.3",
"laravel/passport": "^13.0.5", "laravel/passport": "^12.0",
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
"league/csv": "^9.16.0", "league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3", "league/iso3166": "^4.3",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
"novadaemon/filament-pretty-json": "^2.2", "novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^12.0.4", "nwidart/laravel-modules": "^11.0.11",
"owen-it/laravel-auditing": "^14.0.0", "owen-it/laravel-auditing": "^13.6",
"pxlrbt/filament-environment-indicator": "^2.1.0", "pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2", "spatie/temporary-directory": "^2.2",
"staudenmeir/eloquent-json-relations": "^1.1", "staudenmeir/eloquent-json-relations": "^1.1",
"stechstudio/filament-impersonate": "^3.8", "stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0", "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" "wikimedia/composer-merge-plugin": "^2.1.0"
}, },
"require-dev": { "require-dev": {
@@ -43,13 +43,14 @@
"brianium/paratest": "^7.3", "brianium/paratest": "^7.3",
"fakerphp/faker": "^1.9.1", "fakerphp/faker": "^1.9.1",
"fumeapp/modeltyper": "^3.0", "fumeapp/modeltyper": "^3.0",
"larastan/larastan": "^3.5.0", "phpstan/phpstan": "1.12.0",
"larastan/larastan": "^2.0",
"laravel/pint": "^1.0", "laravel/pint": "^1.0",
"laravel/sail": "^1.18", "laravel/sail": "^1.18",
"laravel/telescope": "^5.0", "laravel/telescope": "^5.0",
"mockery/mockery": "^1.4.4", "mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^8.1", "nunomaduro/collision": "^8.1",
"phpunit/phpunit": "^12", "phpunit/phpunit": "^11",
"spatie/laravel-ignition": "^2.0", "spatie/laravel-ignition": "^2.0",
"timacdonald/log-fake": "^2.1" "timacdonald/log-fake": "^2.1"
}, },
@@ -118,8 +119,7 @@
"extra": { "extra": {
"laravel": { "laravel": {
"dont-discover": [ "dont-discover": [
"laravel/telescope", "laravel/telescope"
"nwidart/laravel-modules"
] ]
} }
}, },

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

View File

@@ -34,15 +34,31 @@ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Passport Database Connection | Client UUIDs
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| By default, Passport's models will utilize your application's default | By default, Passport uses auto-incrementing primary keys when assigning
| database connection. If you wish to use a different connection you | IDs to clients. However, if Passport is installed using the provided
| may specify the configured name of the database connection here. | --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' => [ 'tasks' => [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true), '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_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_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false), '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\Enums\TimeFormat;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\CurrencyService;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@@ -28,7 +27,7 @@ class OrganizationFactory extends Factory
{ {
return [ return [
'name' => $this->faker->unique()->company(), 'name' => $this->faker->unique()->company(),
'currency' => app(CurrencyService::class)->getRandomCurrencyCode(), 'currency' => $this->faker->currencyCode(),
'billable_rate' => null, 'billable_rate' => null,
'user_id' => User::factory(), 'user_id' => User::factory(),
'personal_team' => true, 'personal_team' => true,

View File

@@ -7,12 +7,11 @@ namespace Database\Factories\Passport;
use App\Models\Passport\Client; use App\Models\Passport\Client;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Laravel\Passport\Database\Factories\ClientFactory as BaseClientFactory;
/** /**
* @extends Factory<Client> * @extends Factory<Client>
*/ */
class ClientFactory extends BaseClientFactory class ClientFactory extends Factory
{ {
/** /**
* Define the model's default state. * Define the model's default state.
@@ -23,40 +22,24 @@ class ClientFactory extends BaseClientFactory
{ {
return [ return [
'id' => $this->faker->uuid, 'id' => $this->faker->uuid,
'owner_id' => null, 'user_id' => null,
'owner_type' => null,
'name' => $this->faker->company(), 'name' => $this->faker->company(),
'secret' => $this->faker->regexify('[A-Za-z]{40}'), 'secret' => $this->faker->regexify('[A-Za-z]{40}'),
'provider' => 'users', 'provider' => 'users',
'redirect_uris' => [$this->faker->url()], 'redirect' => $this->faker->url(),
'grant_types' => [], 'personal_access_client' => false,
'password_client' => false,
'revoked' => false, 'revoked' => false,
'created_at' => $this->faker->dateTime(), 'created_at' => $this->faker->dateTime(),
'updated_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 public function personalAccessClient(): self
{ {
return $this->state(function (array $attributes) { return $this->state(function (array $attributes) {
return [ 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 $this->state(function (array $attributes) use ($user): array {
return [ return [
'owner_id' => $user->getKey(), 'user_id' => $user->getKey(),
'owner_type' => (new User)->getMorphClass(),
]; ];
}); });
} }

View File

@@ -31,8 +31,6 @@ class TokenFactory extends Factory
'created_at' => $this->faker->dateTime, 'created_at' => $this->faker->dateTime,
'updated_at' => $this->faker->dateTime, 'updated_at' => $this->faker->dateTime,
'expires_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 public function start(Carbon $start): self
{ {
return $this->state(function (array $attributes) use ($start): array { 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\AuthCode;
use Laravel\Passport\Client as PassportClient; use Laravel\Passport\Client as PassportClient;
use Laravel\Passport\ClientRepository; use Laravel\Passport\ClientRepository;
use Laravel\Passport\PersonalAccessClient;
use Laravel\Passport\RefreshToken; use Laravel\Passport\RefreshToken;
use Laravel\Passport\Token; use Laravel\Passport\Token;
@@ -36,18 +37,6 @@ class DatabaseSeeder extends Seeder
{ {
$this->deleteAll(); $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( app(ClientRepository::class)->create(
null, null,
'desktop', 'desktop',
@@ -57,16 +46,17 @@ class DatabaseSeeder extends Seeder
false, false,
false false
); );
*/
$personalAccessClient = new PassportClient; $personalAccessClient = new PassportClient;
$personalAccessClient->id = config('passport.personal_access_client.id'); $personalAccessClient->id = config('passport.personal_access_client.id');
$personalAccessClient->secret = config('passport.personal_access_client.secret'); $personalAccessClient->secret = config('passport.personal_access_client.secret');
$personalAccessClient->name = 'API'; $personalAccessClient->name = 'API';
$personalAccessClient->redirect_uris = ['http://localhost']; $personalAccessClient->redirect = 'http://localhost';
$personalAccessClient->user_id = null;
$personalAccessClient->revoked = false; $personalAccessClient->revoked = false;
$personalAccessClient->provider = 'users'; $personalAccessClient->provider = null;
$personalAccessClient->grant_types = ['personal_access']; $personalAccessClient->personal_access_client = true;
$personalAccessClient->password_client = false;
$personalAccessClient->save(); $personalAccessClient->save();
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([ $userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
@@ -207,6 +197,7 @@ class DatabaseSeeder extends Seeder
DB::table((new RefreshToken)->getTable())->delete(); DB::table((new RefreshToken)->getTable())->delete();
DB::table((new Token)->getTable())->delete(); DB::table((new Token)->getTable())->delete();
DB::table((new AuthCode)->getTable())->delete(); DB::table((new AuthCode)->getTable())->delete();
DB::table((new PersonalAccessClient)->getTable())->delete();
DB::table((new PassportClient)->getTable())->delete(); DB::table((new PassportClient)->getTable())->delete();
// Internal tables // Internal tables

View File

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

View File

@@ -2,7 +2,7 @@
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically # Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
/usr/bin/mc alias set local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY}; /usr/bin/mc config host add local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
/usr/bin/mc rm -r --force local/${S3_BUCKET}; /usr/bin/mc rm -r --force local/${S3_BUCKET};
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET}; /usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
/usr/bin/mc anonymous set public local/${S3_BUCKET}; /usr/bin/mc anonymous set public local/${S3_BUCKET};

View File

@@ -1,30 +1,47 @@
# Accepted values: 8.3 - 8.2
ARG PHP_VERSION=8.3 ARG PHP_VERSION=8.3
ARG FRANKENPHP_VERSION=1.8
ARG COMPOSER_VERSION=2.8 ARG FRANKENPHP_VERSION=latest
ARG BUN_VERSION="latest"
ARG APP_ENV ARG COMPOSER_VERSION=latest
ARG DOCKER_FILES_BASE_PATH="docker/prod/" 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 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 ARG DOCKER_FILES_BASE_PATH
ARG TARGETPLATFORM
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 v2.10.0 \
--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
LABEL maintainer="solidtime <hello@solidtime.io>" LABEL maintainer="solidtime <hello@solidtime.io>"
LABEL org.opencontainers.image.title="solidtime" LABEL org.opencontainers.image.title="solidtime"
@@ -36,22 +53,18 @@ ARG WWWUSER=1000
ARG WWWGROUP=1000 ARG WWWGROUP=1000
ARG TZ=UTC ARG TZ=UTC
ARG APP_DIR=/var/www/html ARG APP_DIR=/var/www/html
ARG APP_ENV
ARG APP_HOST
ARG DOCKER_FILES_BASE_PATH
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-color \ TERM=xterm-color \
WITH_HORIZON=false \
WITH_SCHEDULER=false \
OCTANE_SERVER=frankenphp \ OCTANE_SERVER=frankenphp \
TZ=${TZ} \
USER=octane \ USER=octane \
ROOT=${APP_DIR} \ ROOT=${APP_DIR} \
APP_ENV=${APP_ENV} \
COMPOSER_FUND=0 \ COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=24 \ COMPOSER_MAX_PARALLEL_HTTP=24 \
XDG_CONFIG_HOME=${APP_DIR}/.config \ XDG_CONFIG_HOME=${APP_DIR}/.config \
XDG_DATA_HOME=${APP_DIR}/.data \ XDG_DATA_HOME=${APP_DIR}/.data
SERVER_NAME=${APP_HOST}
WORKDIR ${ROOT} WORKDIR ${ROOT}
@@ -65,16 +78,14 @@ RUN apt-get update; \
apt-get install -yqq --no-install-recommends --show-progress \ apt-get install -yqq --no-install-recommends --show-progress \
apt-utils \ apt-utils \
curl \ curl \
gcc \
wget \ wget \
vim \ nano \
git \
ncdu \ ncdu \
procps \ procps \
unzip \
ca-certificates \ ca-certificates \
supervisor \ supervisor \
libsodium-dev \ libsodium-dev \
libbrotli-dev \
# Install PHP extensions (included with dunglas/frankenphp) # Install PHP extensions (included with dunglas/frankenphp)
&& install-php-extensions \ && install-php-extensions \
bz2 \ bz2 \
@@ -88,8 +99,6 @@ RUN apt-get update; \
exif \ exif \
pdo_mysql \ pdo_mysql \
zip \ zip \
uv \
vips \
intl \ intl \
gd \ gd \
redis \ redis \
@@ -119,34 +128,27 @@ RUN arch="$(uname -m)" \
RUN userdel --remove --force www-data \ RUN userdel --remove --force www-data \
&& groupadd --force -g ${WWWGROUP} ${USER} \ && groupadd --force -g ${WWWGROUP} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \ && useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER}
&& setcap -r /usr/local/bin/frankenphp
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \ RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
&& chmod -R a+rw ${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 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} USER ${USER}
COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer COPY --chown=${USER}:${USER} --from=vendor /usr/bin/composer /usr/bin/composer
#COPY --chown=${USER}:${USER} composer.json composer.lock ./
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} . .
# #
#RUN composer install \ #RUN composer install \
# --no-dev \ # --no-dev \
@@ -156,47 +158,22 @@ RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
# --no-scripts \ # --no-scripts \
# --audit # --audit
########################################### COPY --chown=${USER}:${USER} . .
# Build frontend assets with Bun #COPY --chown=${USER}:${USER} --from=build ${ROOT}/public public
###########################################
#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
RUN mkdir -p \ RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \ storage/framework/{sessions,views,cache,testing} \
storage/logs \ storage/logs \
bootstrap/cache && chmod -R a+rw storage 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 \ #RUN composer install \
# --classmap-authoritative \ # --classmap-authoritative \
# --no-interaction \ # --no-interaction \
@@ -206,9 +183,12 @@ RUN mkdir -p \
RUN cat .env RUN cat .env
#RUN php artisan 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 EXPOSE 8000
ENTRYPOINT ["start-container"] 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] [program:octane]
process_name = %(program_name)s_%(process_num)s process_name=%(program_name)s_%(process_num)02d
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 command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019
user = %(ENV_USER)s ; command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=localhost --port=443 --admin-port=2019 --https --http-redirect
priority = 1 user=%(ENV_USER)s
autostart = true autostart=true
autorestart = true autorestart=true
environment = LARAVEL_OCTANE = "1" environment=LARAVEL_OCTANE="1"
stdout_logfile = /dev/stdout stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes=0
stderr_logfile = /dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes = 0 stderr_logfile_maxbytes=0
[program:horizon] [program:horizon]
process_name = %(program_name)s_%(process_num)s process_name=%(program_name)s_%(process_num)02d
command = php %(ENV_ROOT)s/artisan horizon command=php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s user=%(ENV_USER)s
priority = 3 autostart=%(ENV_WITH_HORIZON)s
autostart = %(ENV_WITH_HORIZON)s autorestart=true
autorestart = true stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log stdout_logfile_maxbytes=200MB
stdout_logfile_maxbytes = 200MB stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log stderr_logfile_maxbytes=200MB
stderr_logfile_maxbytes = 200MB stopwaitsecs=3600
stopwaitsecs = 3600
[program:scheduler] [program:scheduler]
process_name = %(program_name)s_%(process_num)s process_name=%(program_name)s_%(process_num)02d
command = supercronic -overlapping /etc/supercronic/laravel command=supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s user=%(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s autostart=%(ENV_WITH_SCHEDULER)s
autorestart = true autorestart=true
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes = 200MB stdout_logfile_maxbytes=200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes = 200MB stderr_logfile_maxbytes=200MB
[program:clear-scheduler-cache] [program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s process_name=%(program_name)s_%(process_num)02d
command = php %(ENV_ROOT)s/artisan schedule:clear-cache command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s user=%(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s autostart=%(ENV_WITH_SCHEDULER)s
autorestart = false autorestart=false
startsecs = 0 startsecs=0
startretries = 1 startretries=1
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes = 200MB stdout_logfile_maxbytes=200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes = 200MB 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
[include] [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_size = 16M
realpath_cache_ttl = 360 realpath_cache_ttl = 360
max_input_time = 5 max_input_time = 5
register_argc_argv = 0
date.timezone = ${TZ:-UTC}
[Opcache] [Opcache]
opcache.enable = 1 opcache.enable = 1
@@ -18,6 +16,7 @@ opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0 opcache.validate_timestamps = 0
opcache.file_update_protection = 0 opcache.file_update_protection = 0
opcache.interned_strings_buffer = 16 opcache.interned_strings_buffer = 16
opcache.file_cache = 60
[JIT] [JIT]
opcache.jit_buffer_size = 128M opcache.jit_buffer_size = 128M

View File

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

View File

@@ -1,13 +1,14 @@
[supervisord] [supervisord]
nodaemon = true nodaemon=true
user = %(ENV_USER)s user=%(ENV_USER)s
logfile = /var/log/supervisor/supervisord.log logfile=/var/log/supervisor/supervisord.log
pidfile = /var/run/supervisord.pid pidfile=/var/run/supervisord.pid
[unix_http_server]
file=/var/run/supervisor.sock
[supervisorctl] [supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[inet_http_server]
port = 127.0.0.1:9001
[rpcinterface:supervisor] [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] [program:horizon]
process_name = %(program_name)s_%(process_num)s process_name=%(program_name)s_%(process_num)02d
command = php %(ENV_ROOT)s/artisan horizon command=php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s user=%(ENV_USER)s
autostart = true autostart=true
autorestart = true autorestart=true
stdout_logfile = /dev/stdout stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes=0
stderr_logfile = /dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes = 0 stderr_logfile_maxbytes=0
stopwaitsecs = 3600 stopwaitsecs=3600
[include] [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] [program:scheduler]
process_name = %(program_name)s_%(process_num)s process_name=%(program_name)s_%(process_num)02d
command = supercronic -overlapping /etc/supercronic/laravel command=supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s user=%(ENV_USER)s
autostart = true autostart=true
autorestart = true autorestart=true
stdout_logfile = /dev/stdout stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes=0
stderr_logfile = /dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes = 0 stderr_logfile_maxbytes=0
[program:clear-scheduler-cache] [program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s process_name=%(program_name)s_%(process_num)02d
command = php %(ENV_ROOT)s/artisan schedule:clear-cache command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s user=%(ENV_USER)s
autostart = true autostart=true
autorestart = false autorestart=false
startsecs = 0 startsecs=0
startretries = 1 startretries=1
stdout_logfile = /dev/stdout stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes=0
stderr_logfile = /dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes = 0 stderr_logfile_maxbytes=0
[include] [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] [program:worker]
process_name = %(program_name)s_%(process_num)s process_name=%(program_name)s_%(process_num)02d
command = %(ENV_WORKER_COMMAND)s command=%(ENV_WORKER_COMMAND)s
user = %(ENV_USER)s user=%(ENV_USER)s
autostart = true autostart=true
autorestart = true autorestart=true
stdout_logfile = /dev/stdout stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes=0
stderr_logfile = /dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes = 0 stderr_logfile_maxbytes=0
[include] [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"

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