diff --git a/.env.example b/.env.example index 64023886..abf225b1 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,7 @@ PUSHER_PORT=443 PUSHER_SCHEME=https PUSHER_APP_CLUSTER=mt1 +VITE_HOST_NAME=vite.solidtime.test VITE_APP_NAME="${APP_NAME}" VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" VITE_PUSHER_HOST="${PUSHER_HOST}" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0987d1be --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + target-branch: "develop" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + target-branch: "develop" diff --git a/.github/workflows/npm-build.yml b/.github/workflows/npm-build.yml index 9e43e262..85f2f667 100644 --- a/.github/workflows/npm-build.yml +++ b/.github/workflows/npm-build.yml @@ -17,7 +17,7 @@ jobs: extensions: intl, zip - run: composer install - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '20.x' - run: npm ci diff --git a/.github/workflows/npm-lint.yml b/.github/workflows/npm-lint.yml index 1c280e98..6adc4c61 100644 --- a/.github/workflows/npm-lint.yml +++ b/.github/workflows/npm-lint.yml @@ -12,7 +12,7 @@ jobs: uses: actions/checkout@v4 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '20.x' - run: npm ci diff --git a/.github/workflows/npm-typecheck.yml b/.github/workflows/npm-typecheck.yml index 795e0c44..27843604 100644 --- a/.github/workflows/npm-typecheck.yml +++ b/.github/workflows/npm-typecheck.yml @@ -12,7 +12,7 @@ jobs: uses: actions/checkout@v4 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '20.x' - run: npm ci diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 740d2d53..dd65dbfc 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -34,7 +34,7 @@ jobs: - name: "Run composer install" run: composer install -n --prefer-dist - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: '20.x' @@ -50,5 +50,5 @@ jobs: php artisan key:generate php artisan passport:keys - - name: "Run PHPUnit" - run: composer test + - name: "Run PHPUnit in parallel" + run: composer ptest diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 56c2afd9..04356a3f 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -30,7 +30,7 @@ jobs: - name: "Checkout code" uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: '20.x' diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index c521b558..feee9aef 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Actions\Fortify; +use App\Enums\Weekday; use App\Models\User; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Support\Facades\Validator; @@ -24,6 +25,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], 'timezone' => ['required', 'timezone:all'], + 'week_start' => ['required', Rule::enum(Weekday::class)], ])->validateWithBag('updateProfileInformation'); if (isset($input['photo'])) { @@ -38,6 +40,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation 'name' => $input['name'], 'email' => $input['email'], 'timezone' => $input['timezone'], + 'week_start' => $input['week_start'], ])->save(); } } diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php index dd43ee5d..8d3db618 100644 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ b/app/Actions/Jetstream/AddOrganizationMember.php @@ -26,7 +26,7 @@ class AddOrganizationMember implements AddsTeamMembers */ public function add(User $owner, Organization $organization, string $email, ?string $role = null): void { - Gate::forUser($owner)->authorize('addTeamMember', $organization); + Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring $this->validate($organization, $email, $role); diff --git a/app/Enums/Weekday.php b/app/Enums/Weekday.php new file mode 100644 index 00000000..8181b5c2 --- /dev/null +++ b/app/Enums/Weekday.php @@ -0,0 +1,47 @@ + Carbon::MONDAY, + Weekday::Tuesday => Carbon::TUESDAY, + Weekday::Wednesday => Carbon::WEDNESDAY, + Weekday::Thursday => Carbon::THURSDAY, + Weekday::Friday => Carbon::FRIDAY, + Weekday::Saturday => Carbon::SATURDAY, + Weekday::Sunday => Carbon::SUNDAY, + }; + } + + /** + * @return array + */ + public static function toSelectArray(): array + { + return [ + Weekday::Monday->value => __('enum.weekday.'.Weekday::Monday->value), + Weekday::Tuesday->value => __('enum.weekday.'.Weekday::Tuesday->value), + Weekday::Wednesday->value => __('enum.weekday.'.Weekday::Wednesday->value), + Weekday::Thursday->value => __('enum.weekday.'.Weekday::Thursday->value), + Weekday::Friday->value => __('enum.weekday.'.Weekday::Friday->value), + Weekday::Saturday->value => __('enum.weekday.'.Weekday::Saturday->value), + Weekday::Sunday->value => __('enum.weekday.'.Weekday::Sunday->value), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 5252898f..7a7e0ccd 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -91,6 +91,7 @@ class ProjectController extends Controller $this->checkPermission($organization, 'projects:update', $project); $project->name = $request->input('name'); $project->color = $request->input('color'); + $project->client_id = $request->input('project_id'); $project->save(); return new ProjectResource($project); diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php new file mode 100644 index 00000000..dd068435 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -0,0 +1,106 @@ +organization_id !== $organization->id) { + throw new AuthorizationException('Task does not belong to organization'); + } + } + + /** + * Get tasks + * + * @return TaskCollection + * + * @throws AuthorizationException + * + * @operationId getTasks + */ + public function index(Organization $organization, TaskIndexRequest $request): TaskCollection + { + $this->checkPermission($organization, 'tasks:view'); + + $projectId = $request->input('project_id'); + + $query = Task::query() + ->whereBelongsTo($organization, 'organization'); + + if ($projectId !== null) { + $query->where('project_id', '=', $projectId); + } + + $tasks = $query->paginate(); + + return new TaskCollection($tasks); + } + + /** + * Create task + * + * @throws AuthorizationException + * + * @operationId createTask + */ + public function store(Organization $organization, TaskStoreRequest $request): JsonResource + { + $this->checkPermission($organization, 'tasks:create'); + $task = new Task(); + $task->name = $request->input('name'); + $task->project_id = $request->input('project_id'); + $task->organization()->associate($organization); + $task->save(); + + return new TaskResource($task); + } + + /** + * Update task + * + * @throws AuthorizationException + * + * @operationId updateTask + */ + public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource + { + $this->checkPermission($organization, 'tasks:update', $task); + $task->name = $request->input('name'); + $task->save(); + + return new TaskResource($task); + } + + /** + * Delete task + * + * @throws AuthorizationException + * + * @operationId deleteTask + */ + public function destroy(Organization $organization, Task $task): JsonResponse + { + $this->checkPermission($organization, 'tasks:delete', $task); + + $task->delete(); + + return response() + ->json(null, 204); + } +} diff --git a/app/Http/Requests/V1/Task/TaskIndexRequest.php b/app/Http/Requests/V1/Task/TaskIndexRequest.php new file mode 100644 index 00000000..a80b7c1d --- /dev/null +++ b/app/Http/Requests/V1/Task/TaskIndexRequest.php @@ -0,0 +1,36 @@ +> + */ + public function rules(): array + { + return [ + 'project_id' => [ + 'uuid', + new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + ]; + } +} diff --git a/app/Http/Requests/V1/Task/TaskStoreRequest.php b/app/Http/Requests/V1/Task/TaskStoreRequest.php new file mode 100644 index 00000000..4b96db01 --- /dev/null +++ b/app/Http/Requests/V1/Task/TaskStoreRequest.php @@ -0,0 +1,43 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + // TODO: unique + 'required', + 'string', + 'min:1', + 'max:255', + ], + 'project_id' => [ + 'required', + new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + ]; + } +} diff --git a/app/Http/Requests/V1/Task/TaskUpdateRequest.php b/app/Http/Requests/V1/Task/TaskUpdateRequest.php new file mode 100644 index 00000000..ad5783a5 --- /dev/null +++ b/app/Http/Requests/V1/Task/TaskUpdateRequest.php @@ -0,0 +1,42 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + // TODO: unique + 'required', + 'string', + 'min:1', + 'max:255', + ], + 'project_id' => [ + new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + ]; + } +} diff --git a/app/Http/Resources/V1/Task/TaskCollection.php b/app/Http/Resources/V1/Task/TaskCollection.php new file mode 100644 index 00000000..d62024fa --- /dev/null +++ b/app/Http/Resources/V1/Task/TaskCollection.php @@ -0,0 +1,18 @@ + + */ + public function toArray(Request $request): array + { + return [ + /** @var string $id ID */ + 'id' => $this->resource->id, + /** @var string $name Name */ + 'name' => $this->resource->name, + /** @var string $project_id ID of the project */ + 'project_id' => $this->resource->project_id, + /** @var string $created_at When the tag was created */ + 'created_at' => $this->formatDateTime($this->resource->created_at), + /** @var string $updated_at When the tag was last updated */ + 'updated_at' => $this->formatDateTime($this->resource->updated_at), + ]; + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php index 3909ed09..6f2122df 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -9,12 +9,15 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Carbon; /** * @property string $id * @property string $name * @property string $project_id * @property string $organization_id + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * @property-read Project $project * @property-read Organization $organization * diff --git a/app/Models/User.php b/app/Models/User.php index cff3114a..9f1891e1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\Weekday; use Database\Factories\UserFactory; use Filament\Panel; use Illuminate\Database\Eloquent\Builder; @@ -27,6 +28,7 @@ use Laravel\Passport\HasApiTokens; * @property string|null $password * @property string $timezone * @property bool $is_placeholder + * @property Weekday $week_start * @property Collection $organizations * @property Collection $timeEntries * @@ -79,6 +81,16 @@ class User extends Authenticatable 'email_verified_at' => 'datetime', 'is_admin' => 'boolean', 'is_placeholder' => 'boolean', + 'week_start' => Weekday::class, + ]; + + /** + * The model's default values for attributes. + * + * @var array + */ + protected $attributes = [ + 'week_start' => Weekday::Monday, ]; /** diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 18905a84..9f2bfb46 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -11,6 +11,7 @@ use App\Actions\Jetstream\DeleteUser; use App\Actions\Jetstream\InviteOrganizationMember; use App\Actions\Jetstream\RemoveOrganizationMember; use App\Actions\Jetstream\UpdateOrganization; +use App\Enums\Weekday; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Service\TimezoneService; @@ -58,6 +59,10 @@ class JetstreamServiceProvider extends ServiceProvider 'projects:create', 'projects:update', 'projects:delete', + 'tasks:view', + 'tasks:create', + 'tasks:update', + 'tasks:delete', 'time-entries:view:all', 'time-entries:create:all', 'time-entries:update:all', @@ -86,6 +91,10 @@ class JetstreamServiceProvider extends ServiceProvider 'projects:create', 'projects:update', 'projects:delete', + 'tasks:view', + 'tasks:create', + 'tasks:update', + 'tasks:delete', 'time-entries:view:all', 'time-entries:create:all', 'time-entries:update:all', @@ -105,6 +114,7 @@ class JetstreamServiceProvider extends ServiceProvider Jetstream::role('employee', 'Employee', [ 'projects:view', 'tags:view', + 'tasks:view', 'time-entries:view:own', 'time-entries:create:own', 'time-entries:update:own', @@ -120,6 +130,7 @@ class JetstreamServiceProvider extends ServiceProvider function (Request $request, array $data) { return array_merge($data, [ 'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(), + 'weekdays' => Weekday::toSelectArray(), ]); } ); diff --git a/app/Service/DashboardService.php b/app/Service/DashboardService.php index 616d530e..dfff7fdb 100644 --- a/app/Service/DashboardService.php +++ b/app/Service/DashboardService.php @@ -4,40 +4,92 @@ declare(strict_types=1); namespace App\Service; +use App\Enums\Weekday; use App\Models\TimeEntry; use App\Models\User; use Carbon\Carbon; use Carbon\CarbonTimeZone; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class DashboardService { - /** - * @return array - */ - private function lastDays(int $days, CarbonTimeZone $timeZone): array + private TimezoneService $timezoneService; + + public function __construct(TimezoneService $timezoneService) { - $result = []; - $date = Carbon::now($timeZone); + $this->timezoneService = $timezoneService; + } + + /** + * @return Collection + */ + private function lastDays(int $days, CarbonTimeZone $timeZone): Collection + { + $result = new Collection(); + $date = Carbon::now($timeZone)->subDays($days); for ($i = 0; $i < $days; $i++) { - $result[] = $date->format('Y-m-d'); - $date = $date->subDay(); + $date->addDay(); + $result->push($date->format('Y-m-d')); } return $result; } + /** + * @return Collection + */ + private function daysOfThisWeek(CarbonTimeZone $timeZone, Weekday $weekday): Collection + { + $result = new Collection(); + $date = Carbon::now($timeZone); + $start = $date->startOfWeek($weekday->carbonWeekDay()); + for ($i = 0; $i < 7; $i++) { + $result->push($start->format('Y-m-d')); + $start->addDay(); + } + + return $result; + } + + /** + * @param Collection $possibleDates + * @param Builder $builder + * @return Builder + */ + private function constrainDateByPossibleDates(Builder $builder, Collection $possibleDates, CarbonTimeZone $timeZone): Builder + { + $value1 = Carbon::createFromFormat('Y-m-d', $possibleDates->first(), $timeZone); + $value2 = Carbon::createFromFormat('Y-m-d', $possibleDates->last(), $timeZone); + if ($value2 === false || $value1 === false) { + throw new \RuntimeException('Provided date is not valid'); + } + if ($value1->gt($value2)) { + $last = $value1; + $first = $value2; + } else { + $last = $value2; + $first = $value1; + } + + return $builder->whereBetween('start', [ + $first->startOfDay()->utc(), + $last->endOfDay()->utc(), + ]); + } + /** * Get the daily tracked hours for the user * First value: date * Second value: seconds * - * @return array + * @return array */ public function getDailyTrackedHours(User $user, int $days): array { - $timezone = new CarbonTimeZone($user->timezone); - $timezoneShift = $timezone->getOffset(new \DateTime('now', new \DateTimeZone('UTC'))); + $timezone = $this->timezoneService->getTimezoneFromUser($user); + $timezoneShift = $this->timezoneService->getShiftFromUtc($timezone); if ($timezoneShift > 0) { $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; @@ -47,60 +99,67 @@ class DashboardService $dateWithTimeZone = 'start'; } - $resultDb = TimeEntry::query() - ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as value')) + $possibleDays = $this->lastDays($days, $timezone); + + $query = TimeEntry::query() + ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->where('user_id', '=', $user->id) ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) - ->orderBy('date') - ->get() - ->pluck('value', 'date'); + ->orderBy('date'); + + $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); + $resultDb = $query->get() + ->pluck('aggregate', 'date'); $result = []; - $lastDays = $this->lastDays($days, $timezone); - foreach ($lastDays as $day) { - $result[] = [$day, (int) ($resultDb->get($day) ?? 0)]; + foreach ($possibleDays as $possibleDay) { + $result[] = [ + 'date' => $possibleDay, + 'duration' => (int) ($resultDb->get($possibleDay) ?? 0), + ]; } return $result; } /** - * Statistics for the current week starting at Monday / Sunday + * Statistics for the current week starting at weekday of users preference * * @return array */ public function getWeeklyHistory(User $user): array { - return [ - [ - 'date' => '2024-02-26', - 'duration' => 3600, - ], - [ - 'date' => '2024-02-27', - 'duration' => 2000, - ], - [ - 'date' => '2024-02-28', - 'duration' => 4000, - ], - [ - 'date' => '2024-02-29', - 'duration' => 3000, - ], - [ - 'date' => '2024-03-01', - 'duration' => 5000, - ], - [ - 'date' => '2024-03-02', - 'duration' => 3000, - ], - [ - 'date' => '2024-03-03', - 'duration' => 2000, - ], - ]; + $timezone = $this->timezoneService->getTimezoneFromUser($user); + $timezoneShift = $this->timezoneService->getShiftFromUtc($timezone); + if ($timezoneShift > 0) { + $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; + } elseif ($timezoneShift < 0) { + $dateWithTimeZone = 'start - INTERVAL \''.abs($timezoneShift).' second\''; + } else { + $dateWithTimeZone = 'start'; + } + $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start); + + $query = TimeEntry::query() + ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) + ->where('user_id', '=', $user->id) + ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) + ->orderBy('date'); + + $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); + $resultDb = $query->get() + ->pluck('aggregate', 'date'); + + $result = []; + + foreach ($possibleDays as $possibleDay) { + $result[] = [ + 'date' => $possibleDay, + 'duration' => (int) ($resultDb->get($possibleDay) ?? 0), + ]; + } + + return $result; } } diff --git a/app/Service/TimezoneService.php b/app/Service/TimezoneService.php index d3a62509..26267e67 100644 --- a/app/Service/TimezoneService.php +++ b/app/Service/TimezoneService.php @@ -4,8 +4,11 @@ declare(strict_types=1); namespace App\Service; +use App\Models\User; use Carbon\CarbonTimeZone; +use DateTime; use DateTimeZone; +use Illuminate\Support\Facades\Log; class TimezoneService { @@ -19,6 +22,20 @@ class TimezoneService return $tzlist; } + public function getTimezoneFromUser(User $user): CarbonTimeZone + { + try { + return new CarbonTimeZone($user->timezone); + } catch (\Exception $e) { + Log::error('User has a invalid timezone', [ + 'user_id' => $user->getKey(), + 'timezone' => $user->timezone, + ]); + + return new CarbonTimeZone('UTC'); + } + } + /** * @return array */ @@ -37,4 +54,11 @@ class TimezoneService { return in_array($timezone, $this->getTimezones(), true); } + + public function getShiftFromUtc(CarbonTimeZone $timeZone): int + { + $timezoneShift = $timeZone->getOffset(new DateTime('now', new DateTimeZone('UTC'))); + + return $timezoneShift; + } } diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index e1efdea6..9124b4ba 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -30,7 +30,7 @@ class OrganizationFactory extends Factory public function withOwner(?User $owner = null): self { return $this->state(fn (array $attributes) => [ - 'user_id' => $owner === null ? User::factory() : $owner, + 'user_id' => $owner === null ? User::factory() : $owner->getKey(), ]); } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 070c647b..dd361c2e 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Database\Factories; +use App\Enums\Weekday; use App\Models\Organization; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -27,12 +28,14 @@ class UserFactory extends Factory 'email_verified_at' => now(), 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 'two_factor_secret' => null, + 'two_factor_confirmed_at' => null, 'two_factor_recovery_codes' => null, 'remember_token' => Str::random(10), 'profile_photo_path' => null, 'current_team_id' => null, 'is_placeholder' => false, 'timezone' => 'Europe/Vienna', + 'week_start' => Weekday::Monday, ]; } diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index f6cd3bc7..b9cd5898 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -24,6 +24,15 @@ return new class extends Migration $table->foreignUuid('current_team_id')->nullable(); $table->string('profile_photo_path', 2048)->nullable(); $table->string('timezone'); + $table->enum('week_start', [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ]); $table->timestamps(); $table->uniqueIndex('email') diff --git a/docker-compose.yml b/docker-compose.yml index 4f4bd529..c3c40774 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: laravel.test: build: - context: ./vendor/laravel/sail/runtimes/8.3 + context: ./docker/8.3 dockerfile: Dockerfile args: WWWGROUP: '${WWWGROUP}' @@ -13,9 +13,11 @@ services: - "traefik.http.routers.solidtime.entrypoints=web" - "traefik.http.services.solidtime.loadbalancer.server.port=80" - "traefik.http.routers.solidtime.service=solidtime" + - "traefik.http.services.solidtime-https.loadbalancer.server.port=80" - "traefik.http.routers.solidtime-https.rule=Host(`${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-https.entrypoints=websecure" - "traefik.http.routers.solidtime-https.tls=true" + - "traefik.http.routers.solidtime-https.service=solidtime-https" # vite - "traefik.http.services.solidtime-vite.loadbalancer.server.port=5173" # http @@ -49,7 +51,7 @@ services: POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' volumes: - 'sail-pgsql:/var/lib/postgresql/data' - - './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' + - './docker/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' networks: - sail healthcheck: @@ -72,7 +74,7 @@ services: POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' volumes: - 'sail-pgsql-test:/var/lib/postgresql/data' - - './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' + - './docker/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' networks: - sail healthcheck: diff --git a/docker/8.3/Dockerfile b/docker/8.3/Dockerfile new file mode 100644 index 00000000..b2a1f39c --- /dev/null +++ b/docker/8.3/Dockerfile @@ -0,0 +1,65 @@ +FROM ubuntu:22.04 + +LABEL maintainer="Taylor Otwell" + +ARG WWWGROUP +ARG NODE_VERSION=20 +ARG POSTGRES_VERSION=15 + +WORKDIR /var/www/html + +ENV DEBIAN_FRONTEND noninteractive +ENV TZ=UTC +ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" +ENV SUPERVISOR_PHP_USER="sail" + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN apt-get update \ + && mkdir -p /etc/apt/keyrings \ + && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python2 dnsutils librsvg2-bin fswatch ffmpeg \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x14aa40ec0831756756d7f66c4f4ea0aae5267a6c' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y php8.3-cli php8.3-dev \ + php8.3-pgsql php8.3-sqlite3 php8.3-gd \ + php8.3-curl \ + php8.3-imap php8.3-mysql php8.3-mbstring \ + php8.3-xml php8.3-zip php8.3-bcmath php8.3-soap \ + php8.3-intl php8.3-readline \ + php8.3-ldap \ + php8.3-msgpack php8.3-igbinary php8.3-redis php8.3-swoole \ + php8.3-memcached php8.3-pcov php8.3-imagick php8.3-xdebug \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && npm install -g npm \ + && npm install -g pnpm \ + && npm install -g bun \ + && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ + && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt jammy-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y yarn \ + && apt-get install -y mysql-client \ + && apt-get install -y postgresql-client-$POSTGRES_VERSION \ + && apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.3 + +RUN groupadd --force -g $WWWGROUP sail +RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail + +COPY start-container /usr/local/bin/start-container +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY php.ini /etc/php/8.3/cli/conf.d/99-sail.ini +RUN chmod +x /usr/local/bin/start-container + +EXPOSE 8000 + +ENTRYPOINT ["start-container"] diff --git a/docker/8.3/php.ini b/docker/8.3/php.ini new file mode 100644 index 00000000..0320d71c --- /dev/null +++ b/docker/8.3/php.ini @@ -0,0 +1,8 @@ +[PHP] +post_max_size = 100M +upload_max_filesize = 100M +variables_order = EGPCS +pcov.directory = . + +[opcache] +opcache.enable_cli=1 diff --git a/docker/8.3/start-container b/docker/8.3/start-container new file mode 100644 index 00000000..40c55dfe --- /dev/null +++ b/docker/8.3/start-container @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then + echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." + exit 1 +fi + +if [ ! -z "$WWWUSER" ]; then + usermod -u $WWWUSER sail +fi + +if [ ! -d /.composer ]; then + mkdir /.composer +fi + +chmod -R ugo+rw /.composer + +if [ $# -gt 0 ]; then + if [ "$SUPERVISOR_PHP_USER" = "root" ]; then + exec "$@" + else + exec gosu $WWWUSER "$@" + fi +else + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf +fi diff --git a/docker/8.3/supervisord.conf b/docker/8.3/supervisord.conf new file mode 100644 index 00000000..656da8a9 --- /dev/null +++ b/docker/8.3/supervisord.conf @@ -0,0 +1,14 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:php] +command=%(ENV_SUPERVISOR_PHP_COMMAND)s +user=%(ENV_SUPERVISOR_PHP_USER)s +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/pgsql/create-testing-database.sql b/docker/pgsql/create-testing-database.sql new file mode 100644 index 00000000..d84dc07b --- /dev/null +++ b/docker/pgsql/create-testing-database.sql @@ -0,0 +1,2 @@ +SELECT 'CREATE DATABASE testing' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'testing')\gexec diff --git a/lang/en/enum.php b/lang/en/enum.php new file mode 100644 index 00000000..6fb1cf7b --- /dev/null +++ b/lang/en/enum.php @@ -0,0 +1,19 @@ + [ + Weekday::Monday->value => 'Monday', + Weekday::Tuesday->value => 'Tuesday', + Weekday::Wednesday->value => 'Wednesday', + Weekday::Thursday->value => 'Thursday', + Weekday::Friday->value => 'Friday', + Weekday::Saturday->value => 'Saturday', + Weekday::Sunday->value => 'Sunday', + ], + +]; diff --git a/phpstan.neon b/phpstan.neon index 273e51aa..14f909b7 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,3 +8,5 @@ parameters: # Level 9 is the highest level level: 7 + + checkOctaneCompatibility: true diff --git a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue index aad47e4c..7e38c313 100644 --- a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue +++ b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue @@ -21,6 +21,7 @@ const form = useForm({ email: props.user.email, photo: null as File | null, timezone: props.user.timezone, + week_start: props.user.week_start, }); const verificationLinkSent = ref(null); @@ -211,14 +212,36 @@ const page = usePage<{ class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm"> + + +
+ + + +