Repository: solidtime-io/solidtime Branch: main Commit: 192c8c3b887a Files: 1208 Total size: 3.9 MB Directory structure: gitextract_ryuvl2mb/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 1_bug_report.yml │ │ └── config.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yml │ └── workflows/ │ ├── build-onpremise.yml │ ├── build-private.yml │ ├── build-public.yml │ ├── generate-api-docs.yml │ ├── npm-build.yml │ ├── npm-format-check.yml │ ├── npm-lint.yml │ ├── npm-publish-api.yml │ ├── npm-publish-ui.yml │ ├── npm-typecheck.yml │ ├── phpstan.yml │ ├── phpunit.yml │ ├── pint.yml │ └── playwright.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── app/ │ ├── Actions/ │ │ ├── Fortify/ │ │ │ ├── CreateNewUser.php │ │ │ ├── PasswordValidationRules.php │ │ │ ├── ResetUserPassword.php │ │ │ ├── UpdateUserPassword.php │ │ │ └── UpdateUserProfileInformation.php │ │ └── Jetstream/ │ │ ├── AddOrganizationMember.php │ │ ├── CreateOrganization.php │ │ ├── DeleteOrganization.php │ │ ├── DeleteUser.php │ │ ├── InviteOrganizationMember.php │ │ ├── RemoveOrganizationMember.php │ │ ├── UpdateMemberRole.php │ │ ├── UpdateOrganization.php │ │ └── ValidateOrganizationDeletion.php │ ├── Console/ │ │ ├── Commands/ │ │ │ ├── Admin/ │ │ │ │ ├── OrganizationDeleteCommand.php │ │ │ │ ├── UserCreateCommand.php │ │ │ │ └── UserVerifyCommand.php │ │ │ ├── Auth/ │ │ │ │ └── AuthSendReminderForExpiringApiTokensCommand.php │ │ │ ├── Correction/ │ │ │ │ └── CorrectionPlaceholderMembersCommand.php │ │ │ ├── Report/ │ │ │ │ └── ReportSetExpiredToPrivateCommand.php │ │ │ ├── SelfHost/ │ │ │ │ ├── SelfHostCheckForUpdateCommand.php │ │ │ │ ├── SelfHostDatabaseConsistency.php │ │ │ │ ├── SelfHostGenerateKeysCommand.php │ │ │ │ └── SelfHostTelemetryCommand.php │ │ │ ├── Test/ │ │ │ │ ├── TestEmailCommand.php │ │ │ │ ├── TestJobCommand.php │ │ │ │ └── TestOutputCommand.php │ │ │ └── TimeEntry/ │ │ │ └── TimeEntrySendStillRunningMailsCommand.php │ │ └── Kernel.php │ ├── Enums/ │ │ ├── CurrencyFormat.php │ │ ├── DateFormat.php │ │ ├── ExportFormat.php │ │ ├── IntervalFormat.php │ │ ├── NumberFormat.php │ │ ├── Role.php │ │ ├── TimeEntryAggregationType.php │ │ ├── TimeEntryAggregationTypeInterval.php │ │ ├── TimeEntryRoundingType.php │ │ ├── TimeFormat.php │ │ └── Weekday.php │ ├── Events/ │ │ ├── AfterCreateOrganization.php │ │ ├── BeforeOrganizationDeletion.php │ │ ├── DatabaseSeederAfterSeed.php │ │ ├── DatabaseSeederBeforeDelete.php │ │ ├── MemberMadeToPlaceholder.php │ │ ├── MemberRemoved.php │ │ └── NewsletterRegistered.php │ ├── Exceptions/ │ │ ├── Api/ │ │ │ ├── ApiException.php │ │ │ ├── CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers.php │ │ │ ├── CanNotRemoveOwnerFromOrganization.php │ │ │ ├── ChangingRoleOfPlaceholderIsNotAllowed.php │ │ │ ├── ChangingRoleToPlaceholderIsNotAllowed.php │ │ │ ├── EntityStillInUseApiException.php │ │ │ ├── FeatureIsNotAvailableInFreePlanApiException.php │ │ │ ├── InactiveUserCanNotBeUsedApiException.php │ │ │ ├── InvitationForTheEmailAlreadyExistsApiException.php │ │ │ ├── OnlyOwnerCanChangeOwnership.php │ │ │ ├── OnlyPlaceholdersCanBeMergedIntoAnotherMember.php │ │ │ ├── OrganizationHasNoSubscriptionButMultipleMembersException.php │ │ │ ├── OrganizationNeedsAtLeastOneOwner.php │ │ │ ├── OverlappingTimeEntryApiException.php │ │ │ ├── PdfRendererIsNotConfiguredException.php │ │ │ ├── PersonalAccessClientIsNotConfiguredException.php │ │ │ ├── ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException.php │ │ │ ├── TimeEntryCanNotBeRestartedApiException.php │ │ │ ├── TimeEntryStillRunningApiException.php │ │ │ ├── UserIsAlreadyMemberOfOrganizationApiException.php │ │ │ ├── UserIsAlreadyMemberOfProjectApiException.php │ │ │ └── UserNotPlaceholderApiException.php │ │ ├── Handler.php │ │ └── MovedToApiException.php │ ├── Extensions/ │ │ ├── Auditing/ │ │ │ └── Resolvers/ │ │ │ └── CustomIpAddressResolver.php │ │ ├── Fortify/ │ │ │ ├── CustomLoginResponse.php │ │ │ └── CustomTwoFactorLoginResponse.php │ │ └── Scramble/ │ │ ├── ApiExceptionTypeToSchema.php │ │ └── PaginatedResourceCollectionTypeToSchema.php │ ├── Filament/ │ │ ├── Resources/ │ │ │ ├── AuditResource/ │ │ │ │ └── Pages/ │ │ │ │ ├── CreateAudit.php │ │ │ │ ├── ListAudits.php │ │ │ │ └── ViewAudit.php │ │ │ ├── AuditResource.php │ │ │ ├── ClientResource/ │ │ │ │ └── Pages/ │ │ │ │ ├── CreateClient.php │ │ │ │ ├── EditClient.php │ │ │ │ └── ListClients.php │ │ │ ├── ClientResource.php │ │ │ ├── FailedJobResource/ │ │ │ │ └── Pages/ │ │ │ │ ├── ListFailedJobs.php │ │ │ │ └── ViewFailedJobs.php │ │ │ ├── FailedJobResource.php │ │ │ ├── OrganizationInvitationResource/ │ │ │ │ └── Pages/ │ │ │ │ ├── EditOrganizationInvitation.php │ │ │ │ ├── ListOrganizationInvitations.php │ │ │ │ └── ViewOrganizationInvitation.php │ │ │ ├── OrganizationInvitationResource.php │ │ │ ├── OrganizationResource/ │ │ │ │ ├── Actions/ │ │ │ │ │ └── DeleteOrganization.php │ │ │ │ ├── Pages/ │ │ │ │ │ ├── CreateOrganization.php │ │ │ │ │ ├── EditOrganization.php │ │ │ │ │ ├── ListOrganizations.php │ │ │ │ │ └── ViewOrganization.php │ │ │ │ └── RelationManagers/ │ │ │ │ ├── InvitationsRelationManager.php │ │ │ │ └── UsersRelationManager.php │ │ │ ├── OrganizationResource.php │ │ │ ├── ProjectMemberResource/ │ │ │ │ └── Pages/ │ │ │ │ ├── CreateProjectMember.php │ │ │ │ ├── EditProjectMember.php │ │ │ │ ├── ListProjectMembers.php │ │ │ │ └── ViewProjectMembers.php │ │ │ ├── ProjectMemberResource.php │ │ │ ├── ProjectResource/ │ │ │ │ ├── Pages/ │ │ │ │ │ ├── CreateProject.php │ │ │ │ │ ├── EditProject.php │ │ │ │ │ └── ListProjects.php │ │ │ │ └── RelationManagers/ │ │ │ │ └── ProjectMembersRelationManager.php │ │ │ ├── ProjectResource.php │ │ │ ├── ReportResource/ │ │ │ │ └── Pages/ │ │ │ │ ├── EditReport.php │ │ │ │ ├── ListReports.php │ │ │ │ └── ViewReport.php │ │ │ ├── ReportResource.php │ │ │ ├── TagResource/ │ │ │ │ └── Pages/ │ │ │ │ ├── CreateTag.php │ │ │ │ ├── EditTag.php │ │ │ │ └── ListTags.php │ │ │ ├── TagResource.php │ │ │ ├── TaskResource/ │ │ │ │ └── Pages/ │ │ │ │ ├── CreateTask.php │ │ │ │ ├── EditTask.php │ │ │ │ └── ListTasks.php │ │ │ ├── TaskResource.php │ │ │ ├── TimeEntryResource/ │ │ │ │ └── Pages/ │ │ │ │ ├── CreateTimeEntry.php │ │ │ │ ├── EditTimeEntry.php │ │ │ │ └── ListTimeEntries.php │ │ │ ├── TimeEntryResource.php │ │ │ ├── TokenResource/ │ │ │ │ └── Pages/ │ │ │ │ ├── ListTokens.php │ │ │ │ └── ViewToken.php │ │ │ ├── TokenResource.php │ │ │ ├── UserResource/ │ │ │ │ ├── Actions/ │ │ │ │ │ └── DeleteUser.php │ │ │ │ ├── Pages/ │ │ │ │ │ ├── CreateUser.php │ │ │ │ │ ├── EditUser.php │ │ │ │ │ ├── ListUsers.php │ │ │ │ │ └── ViewUser.php │ │ │ │ └── RelationManagers/ │ │ │ │ ├── OrganizationsRelationManager.php │ │ │ │ └── OwnedOrganizationsRelationManager.php │ │ │ └── UserResource.php │ │ └── Widgets/ │ │ ├── ActiveUserOverview.php │ │ ├── ServerOverview.php │ │ ├── TimeEntriesCreated.php │ │ ├── TimeEntriesImported.php │ │ └── UserRegistrations.php │ ├── Http/ │ │ ├── Controllers/ │ │ │ ├── Api/ │ │ │ │ └── V1/ │ │ │ │ ├── ApiTokenController.php │ │ │ │ ├── ChartController.php │ │ │ │ ├── ClientController.php │ │ │ │ ├── Controller.php │ │ │ │ ├── CurrencyController.php │ │ │ │ ├── ExportController.php │ │ │ │ ├── ImportController.php │ │ │ │ ├── InvitationController.php │ │ │ │ ├── MemberController.php │ │ │ │ ├── OrganizationController.php │ │ │ │ ├── ProjectController.php │ │ │ │ ├── ProjectMemberController.php │ │ │ │ ├── Public/ │ │ │ │ │ └── ReportController.php │ │ │ │ ├── ReportController.php │ │ │ │ ├── TagController.php │ │ │ │ ├── TaskController.php │ │ │ │ ├── TimeEntryController.php │ │ │ │ ├── UserController.php │ │ │ │ ├── UserMembershipController.php │ │ │ │ └── UserTimeEntryController.php │ │ │ ├── Controller.php │ │ │ └── Web/ │ │ │ ├── Controller.php │ │ │ ├── DashboardController.php │ │ │ ├── HealthCheckController.php │ │ │ └── HomeController.php │ │ ├── Kernel.php │ │ ├── Middleware/ │ │ │ ├── Authenticate.php │ │ │ ├── CheckOrganizationBlocked.php │ │ │ ├── EncryptCookies.php │ │ │ ├── EnsureEmailIsVerified.php │ │ │ ├── ForceHttps.php │ │ │ ├── ForceJsonResponse.php │ │ │ ├── HandleInertiaRequests.php │ │ │ ├── PreventRequestsDuringMaintenance.php │ │ │ ├── RedirectIfAuthenticated.php │ │ │ ├── ShareInertiaData.php │ │ │ ├── TrimStrings.php │ │ │ ├── TrustProxies.php │ │ │ ├── ValidateSignature.php │ │ │ └── VerifyCsrfToken.php │ │ ├── Requests/ │ │ │ └── V1/ │ │ │ ├── ApiToken/ │ │ │ │ └── ApiTokenStoreRequest.php │ │ │ ├── BaseFormRequest.php │ │ │ ├── Client/ │ │ │ │ ├── ClientIndexRequest.php │ │ │ │ ├── ClientStoreRequest.php │ │ │ │ └── ClientUpdateRequest.php │ │ │ ├── Import/ │ │ │ │ └── ImportRequest.php │ │ │ ├── Invitation/ │ │ │ │ ├── InvitationIndexRequest.php │ │ │ │ └── InvitationStoreRequest.php │ │ │ ├── Member/ │ │ │ │ ├── MemberDestroyRequest.php │ │ │ │ ├── MemberIndexRequest.php │ │ │ │ ├── MemberMergeIntoRequest.php │ │ │ │ └── MemberUpdateRequest.php │ │ │ ├── Organization/ │ │ │ │ └── OrganizationUpdateRequest.php │ │ │ ├── Project/ │ │ │ │ ├── ProjectIndexRequest.php │ │ │ │ ├── ProjectStoreRequest.php │ │ │ │ └── ProjectUpdateRequest.php │ │ │ ├── ProjectMember/ │ │ │ │ ├── ProjectMemberIndexRequest.php │ │ │ │ ├── ProjectMemberStoreRequest.php │ │ │ │ └── ProjectMemberUpdateRequest.php │ │ │ ├── Report/ │ │ │ │ ├── ReportIndexRequest.php │ │ │ │ ├── ReportStoreRequest.php │ │ │ │ └── ReportUpdateRequest.php │ │ │ ├── Tag/ │ │ │ │ ├── TagIndexRequest.php │ │ │ │ ├── TagStoreRequest.php │ │ │ │ └── TagUpdateRequest.php │ │ │ ├── Task/ │ │ │ │ ├── TaskIndexRequest.php │ │ │ │ ├── TaskStoreRequest.php │ │ │ │ └── TaskUpdateRequest.php │ │ │ └── TimeEntry/ │ │ │ ├── TimeEntryAggregateExportRequest.php │ │ │ ├── TimeEntryAggregateRequest.php │ │ │ ├── TimeEntryDestroyMultipleRequest.php │ │ │ ├── TimeEntryIndexExportRequest.php │ │ │ ├── TimeEntryIndexRequest.php │ │ │ ├── TimeEntryStoreRequest.php │ │ │ ├── TimeEntryUpdateMultipleRequest.php │ │ │ └── TimeEntryUpdateRequest.php │ │ └── Resources/ │ │ ├── PaginatedResourceCollection.php │ │ └── V1/ │ │ ├── ApiToken/ │ │ │ ├── ApiTokenCollection.php │ │ │ ├── ApiTokenResource.php │ │ │ └── ApiTokenWithAccessTokenResource.php │ │ ├── BaseResource.php │ │ ├── Client/ │ │ │ ├── ClientCollection.php │ │ │ └── ClientResource.php │ │ ├── Invitation/ │ │ │ ├── InvitationCollection.php │ │ │ └── InvitationResource.php │ │ ├── Member/ │ │ │ ├── MemberCollection.php │ │ │ ├── MemberResource.php │ │ │ ├── PersonalMembershipCollection.php │ │ │ └── PersonalMembershipResource.php │ │ ├── Organization/ │ │ │ └── OrganizationResource.php │ │ ├── Project/ │ │ │ ├── ProjectCollection.php │ │ │ └── ProjectResource.php │ │ ├── ProjectMember/ │ │ │ ├── ProjectMemberCollection.php │ │ │ └── ProjectMemberResource.php │ │ ├── Report/ │ │ │ ├── DetailedReportResource.php │ │ │ ├── DetailedWithDataReportResource.php │ │ │ ├── ReportCollection.php │ │ │ └── ReportResource.php │ │ ├── Tag/ │ │ │ ├── TagCollection.php │ │ │ └── TagResource.php │ │ ├── Task/ │ │ │ ├── TaskCollection.php │ │ │ └── TaskResource.php │ │ ├── TimeEntry/ │ │ │ ├── TimeEntryCollection.php │ │ │ └── TimeEntryResource.php │ │ └── User/ │ │ └── UserResource.php │ ├── Jobs/ │ │ ├── RecalculateSpentTimeForProject.php │ │ ├── RecalculateSpentTimeForTask.php │ │ └── Test/ │ │ └── TestJob.php │ ├── Listeners/ │ │ └── RemovePlaceholder.php │ ├── Mail/ │ │ ├── AuthApiTokenExpirationReminderMail.php │ │ ├── AuthApiTokenExpiredMail.php │ │ ├── OrganizationInvitationMail.php │ │ └── TimeEntryStillRunningMail.php │ ├── Models/ │ │ ├── Audit.php │ │ ├── Client.php │ │ ├── Concerns/ │ │ │ ├── CustomAuditable.php │ │ │ └── HasUuids.php │ │ ├── FailedJob.php │ │ ├── Member.php │ │ ├── Organization.php │ │ ├── OrganizationInvitation.php │ │ ├── Passport/ │ │ │ ├── AuthCode.php │ │ │ ├── Client.php │ │ │ ├── RefreshToken.php │ │ │ └── Token.php │ │ ├── Project.php │ │ ├── ProjectMember.php │ │ ├── Report.php │ │ ├── Tag.php │ │ ├── Task.php │ │ ├── TimeEntry.php │ │ └── User.php │ ├── Policies/ │ │ └── OrganizationPolicy.php │ ├── Providers/ │ │ ├── AppServiceProvider.php │ │ ├── AuthServiceProvider.php │ │ ├── EventServiceProvider.php │ │ ├── Filament/ │ │ │ └── AdminPanelProvider.php │ │ ├── FortifyServiceProvider.php │ │ ├── JetstreamServiceProvider.php │ │ ├── RouteServiceProvider.php │ │ └── TelescopeServiceProvider.php │ ├── Rules/ │ │ ├── ColorRule.php │ │ └── CurrencyRule.php │ └── Service/ │ ├── ApiService.php │ ├── BillableRateService.php │ ├── BillingContract.php │ ├── ColorService.php │ ├── CurrencyService.php │ ├── DashboardService.php │ ├── DeletionService.php │ ├── Dto/ │ │ └── ReportPropertiesDto.php │ ├── Export/ │ │ ├── ExportException.php │ │ └── ExportService.php │ ├── Import/ │ │ ├── ImportDatabaseHelper.php │ │ ├── ImportService.php │ │ └── Importers/ │ │ ├── ClockifyProjectsImporter.php │ │ ├── ClockifyTimeEntriesImporter.php │ │ ├── DefaultImporter.php │ │ ├── GenericProjectsImporter.php │ │ ├── GenericTimeEntriesImporter.php │ │ ├── HarvestClientsImporter.php │ │ ├── HarvestProjectsImporter.php │ │ ├── HarvestTimeEntriesImporter.php │ │ ├── ImportException.php │ │ ├── ImporterContract.php │ │ ├── ImporterProvider.php │ │ ├── ReportDto.php │ │ ├── SolidtimeImporter.php │ │ ├── TogglDataImporter.php │ │ └── TogglTimeEntriesImporter.php │ ├── IntervalService.php │ ├── InvitationService.php │ ├── IpLookup/ │ │ ├── IpLookupResponseDto.php │ │ ├── IpLookupServiceContract.php │ │ └── NoIpLookupService.php │ ├── LocalizationService.php │ ├── MemberService.php │ ├── OrganizationInvitationService.php │ ├── OrganizationService.php │ ├── PermissionStore.php │ ├── ReportExport/ │ │ ├── CsvExport.php │ │ ├── TimeEntriesDetailedCsvExport.php │ │ ├── TimeEntriesDetailedExport.php │ │ └── TimeEntriesReportExport.php │ ├── ReportService.php │ ├── TimeEntryAggregationService.php │ ├── TimeEntryFilter.php │ ├── TimeEntryService.php │ ├── TimezoneService.php │ └── UserService.php ├── artisan ├── bootstrap/ │ ├── app.php │ └── cache/ │ └── .gitignore ├── components.json ├── composer.json ├── config/ │ ├── app.php │ ├── audit.php │ ├── auth.php │ ├── broadcasting.php │ ├── cache.php │ ├── cors.php │ ├── database.php │ ├── excel.php │ ├── filament.php │ ├── filesystems.php │ ├── fortify.php │ ├── hashing.php │ ├── jetstream.php │ ├── logging.php │ ├── mail.php │ ├── modules.php │ ├── octane.php │ ├── passport.php │ ├── queue.php │ ├── scheduling.php │ ├── scramble.php │ ├── services.php │ ├── session.php │ ├── telescope.php │ ├── trustedproxy.php │ └── view.php ├── database/ │ ├── .gitignore │ ├── factories/ │ │ ├── AuditFactory.php │ │ ├── ClientFactory.php │ │ ├── FailedJobFactory.php │ │ ├── MemberFactory.php │ │ ├── OrganizationFactory.php │ │ ├── OrganizationInvitationFactory.php │ │ ├── Passport/ │ │ │ ├── ClientFactory.php │ │ │ └── TokenFactory.php │ │ ├── ProjectFactory.php │ │ ├── ProjectMemberFactory.php │ │ ├── ReportFactory.php │ │ ├── TagFactory.php │ │ ├── TaskFactory.php │ │ ├── TimeEntryFactory.php │ │ └── UserFactory.php │ ├── migrations/ │ │ ├── 2014_10_12_000000_create_users_table.php │ │ ├── 2014_10_12_100000_create_password_reset_tokens_table.php │ │ ├── 2014_10_12_200000_add_two_factor_columns_to_users_table.php │ │ ├── 2016_06_01_000001_create_oauth_auth_codes_table.php │ │ ├── 2016_06_01_000002_create_oauth_access_tokens_table.php │ │ ├── 2016_06_01_000003_create_oauth_refresh_tokens_table.php │ │ ├── 2016_06_01_000004_create_oauth_clients_table.php │ │ ├── 2016_06_01_000005_create_oauth_personal_access_clients_table.php │ │ ├── 2018_08_08_100000_create_telescope_entries_table.php │ │ ├── 2019_08_19_000000_create_failed_jobs_table.php │ │ ├── 2019_12_14_000001_create_personal_access_tokens_table.php │ │ ├── 2020_05_21_100000_create_organizations_table.php │ │ ├── 2020_05_21_200000_create_organization_user_table.php │ │ ├── 2020_05_21_300000_create_organization_invitations_table.php │ │ ├── 2024_01_16_161030_create_sessions_table.php │ │ ├── 2024_01_20_110218_create_clients_table.php │ │ ├── 2024_01_20_110439_create_projects_table.php │ │ ├── 2024_01_20_110444_create_tasks_table.php │ │ ├── 2024_01_20_110452_create_tags_table.php │ │ ├── 2024_01_20_110837_create_time_entries_table.php │ │ ├── 2024_03_26_171253_create_project_members_table.php │ │ ├── 2024_04_11_150130_create_jobs_table.php │ │ ├── 2024_04_12_095010_create_cache_table.php │ │ ├── 2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table.php │ │ ├── 2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table.php │ │ ├── 2024_05_13_171020_rename_table_organization_user_to_members.php │ │ ├── 2024_05_22_151226_add_client_id_to_time_entries_table.php │ │ ├── 2024_05_30_175801_add_is_billable_column_to_projects_table.php │ │ ├── 2024_05_30_175825_add_is_imported_column_to_time_entries_table.php │ │ ├── 2024_06_01_000001_create_oauth_device_codes_table.php │ │ ├── 2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete.php │ │ ├── 2024_06_10_161831_reset_billable_rates_with_zero_as_value.php │ │ ├── 2024_06_21_122754_add_is_archived_columns_to_projects_and_clients_table.php │ │ ├── 2024_06_24_114433_add_done_at_to_tasks_table.php │ │ ├── 2024_07_02_134307_add_estimated_time_to_projects_and_tasks_table.php │ │ ├── 2024_07_03_145445_change_data_type_of_id_column_in_failed_jobs_table.php │ │ ├── 2024_07_18_080906_add_still_active_email_sent_at_to_time_entries_table.php │ │ ├── 2024_08_01_104840_create_reports_table.php │ │ ├── 2024_09_02_094105_create_audits_table.php │ │ ├── 2024_09_18_120203_add_spent_time_to_projects_and_tasks_table.php │ │ ├── 2024_10_01_143608_add_employees_can_see_billable_rates_to_organizations_table.php │ │ ├── 2024_11_04_164807_add_foreign_key_to_organizations_and_members_table.php │ │ ├── 2024_11_04_170614_add_foreign_keys_to_oauth_tables.php │ │ ├── 2025_04_03_101827_add_localization_columns_to_organizations_table.php │ │ ├── 2025_04_25_202047_change_data_type_for_spent_time_columns.php │ │ ├── 2025_05_06_152804_fix_typos_in_organizations_table_format_columns.php │ │ ├── 2025_05_16_075757_add_foreign_key_for_current_team_id_in_users_table.php │ │ ├── 2025_06_30_095942_remove_oauth_personal_access_clients_table.php │ │ ├── 2025_06_30_132538_update_oauth_clients_table.php │ │ ├── 2025_07_15_105949_hash_oauth_clients.php │ │ ├── 2025_07_17_104903_add_reminder_sent_at_to_oauth_access_tokens_table.php │ │ ├── 2025_10_02_000001_add_prevent_overlapping_time_entries_to_organizations_table.php │ │ ├── 2025_10_16_000001_extend_time_entry_description.php │ │ └── 2025_10_24_120845_add_employees_can_manage_tasks_to_organizations_table.php │ ├── schema/ │ │ └── pgsql_test-schema.sql │ └── seeders/ │ └── DatabaseSeeder.php ├── docker/ │ ├── local/ │ │ ├── 8.3/ │ │ │ ├── Dockerfile │ │ │ ├── php.ini │ │ │ ├── start-container │ │ │ └── supervisord.conf │ │ ├── minio/ │ │ │ └── create_bucket.sh │ │ └── pgsql/ │ │ └── create-testing-database.sql │ └── prod/ │ ├── Dockerfile │ ├── LICENSE │ └── deployment/ │ ├── healthcheck │ ├── octane/ │ │ └── FrankenPHP/ │ │ ├── Caddyfile │ │ └── supervisord.frankenphp.conf │ ├── php.ini │ ├── start-container │ ├── supervisord.conf │ ├── supervisord.horizon.conf │ ├── supervisord.reverb.conf │ ├── supervisord.scheduler.conf │ └── supervisord.worker.conf ├── docker-compose.yml ├── e2e/ │ ├── auth.spec.ts │ ├── calendar-settings.spec.ts │ ├── calendar.spec.ts │ ├── clients.spec.ts │ ├── command-palette.spec.ts │ ├── dashboard.spec.ts │ ├── import-export.spec.ts │ ├── members.spec.ts │ ├── organization.spec.ts │ ├── profile.spec.ts │ ├── project-members.spec.ts │ ├── projects.spec.ts │ ├── reporting-detailed.spec.ts │ ├── reporting.spec.ts │ ├── shared-reports.spec.ts │ ├── tags.spec.ts │ ├── tasks.spec.ts │ ├── time.spec.ts │ ├── timetracker.spec.ts │ └── utils/ │ ├── api.ts │ ├── currentTimeEntry.ts │ ├── mailpit.ts │ ├── members.ts │ ├── money.ts │ ├── reporting.ts │ ├── table.ts │ └── tags.ts ├── eslint.config.mjs ├── jsconfig.json ├── lang/ │ └── en/ │ ├── auth.php │ ├── enum.php │ ├── exceptions.php │ ├── importer.php │ ├── pagination.php │ ├── passwords.php │ └── validation.php ├── openapi.json ├── package.json ├── phpstan.neon ├── phpunit.xml ├── pint.json ├── playwright/ │ ├── config.ts │ └── fixtures.ts ├── playwright.config.ts ├── postcss.config.js ├── public/ │ ├── .htaccess │ ├── desktop-version/ │ │ ├── latest-linux.yml │ │ ├── latest-mac.yml │ │ └── latest.yml │ ├── favicons/ │ │ ├── browserconfig.xml │ │ └── site.webmanifest │ ├── index.php │ ├── robots.txt │ └── security.txt ├── resources/ │ ├── css/ │ │ ├── app.css │ │ └── filament/ │ │ └── admin/ │ │ ├── tailwind.config.js │ │ └── theme.css │ ├── js/ │ │ ├── Components/ │ │ │ ├── ActionMessage.vue │ │ │ ├── ActionSection.vue │ │ │ ├── ApplicationLogo.vue │ │ │ ├── ApplicationMark.vue │ │ │ ├── AuthenticationCard.vue │ │ │ ├── AuthenticationCardLogo.vue │ │ │ ├── Banner.vue │ │ │ ├── Billing/ │ │ │ │ └── BillingBanner.vue │ │ │ ├── CommandPalette/ │ │ │ │ ├── CommandPaletteProvider.vue │ │ │ │ └── index.ts │ │ │ ├── Common/ │ │ │ │ ├── Card.vue │ │ │ │ ├── Client/ │ │ │ │ │ ├── ClientCreateModal.vue │ │ │ │ │ ├── ClientEditModal.vue │ │ │ │ │ ├── ClientMoreOptionsDropdown.vue │ │ │ │ │ ├── ClientMultiselectDropdown.vue │ │ │ │ │ ├── ClientTable.vue │ │ │ │ │ ├── ClientTableHeading.vue │ │ │ │ │ └── ClientTableRow.vue │ │ │ │ ├── Invitation/ │ │ │ │ │ ├── InvitationMoreOptionsDropdown.vue │ │ │ │ │ ├── InvitationTable.vue │ │ │ │ │ ├── InvitationTableHeading.vue │ │ │ │ │ └── InvitationTableRow.vue │ │ │ │ ├── Member/ │ │ │ │ │ ├── MemberBillableRateModal.vue │ │ │ │ │ ├── MemberBillableSelect.vue │ │ │ │ │ ├── MemberCombobox.vue │ │ │ │ │ ├── MemberDeleteModal.vue │ │ │ │ │ ├── MemberEditModal.vue │ │ │ │ │ ├── MemberInviteModal.vue │ │ │ │ │ ├── MemberMakePlaceholderModal.vue │ │ │ │ │ ├── MemberMergeModal.vue │ │ │ │ │ ├── MemberMoreOptionsDropdown.vue │ │ │ │ │ ├── MemberMultiselectDropdown.vue │ │ │ │ │ ├── MemberOwnershipTransferConfirmModal.vue │ │ │ │ │ ├── MemberRoleSelect.vue │ │ │ │ │ ├── MemberTable.vue │ │ │ │ │ ├── MemberTableHeading.vue │ │ │ │ │ └── MemberTableRow.vue │ │ │ │ ├── Notification/ │ │ │ │ │ └── Notification.vue │ │ │ │ ├── Organization/ │ │ │ │ │ └── OrganizationBillableRateModal.vue │ │ │ │ ├── PageTitle.vue │ │ │ │ ├── Project/ │ │ │ │ │ ├── BaseFilterBadge.vue │ │ │ │ │ ├── ProjectClientFilterBadge.vue │ │ │ │ │ ├── ProjectDropdown.vue │ │ │ │ │ ├── ProjectEditModal.vue │ │ │ │ │ ├── ProjectMoreOptionsDropdown.vue │ │ │ │ │ ├── ProjectMultiselectDropdown.vue │ │ │ │ │ ├── ProjectStatusFilterBadge.vue │ │ │ │ │ ├── ProjectTable.vue │ │ │ │ │ ├── ProjectTableHeading.vue │ │ │ │ │ ├── ProjectTableRow.vue │ │ │ │ │ ├── ProjectsFilterDropdown.vue │ │ │ │ │ └── constants.ts │ │ │ │ ├── ProjectMember/ │ │ │ │ │ ├── ProjectMemberBillableRateModal.vue │ │ │ │ │ ├── ProjectMemberCreateModal.vue │ │ │ │ │ ├── ProjectMemberEditModal.vue │ │ │ │ │ ├── ProjectMemberMoreOptionsDropdown.vue │ │ │ │ │ ├── ProjectMemberTable.vue │ │ │ │ │ ├── ProjectMemberTableHeading.vue │ │ │ │ │ └── ProjectMemberTableRow.vue │ │ │ │ ├── Report/ │ │ │ │ │ ├── ReportCreateModal.vue │ │ │ │ │ ├── ReportEditModal.vue │ │ │ │ │ ├── ReportMoreOptionsDropdown.vue │ │ │ │ │ ├── ReportSaveButton.vue │ │ │ │ │ ├── ReportTable.vue │ │ │ │ │ ├── ReportTableHeading.vue │ │ │ │ │ └── ReportTableRow.vue │ │ │ │ ├── Reporting/ │ │ │ │ │ ├── ReportingChart.vue │ │ │ │ │ ├── ReportingExportButton.vue │ │ │ │ │ ├── ReportingExportModal.vue │ │ │ │ │ ├── ReportingFilterBadge.vue │ │ │ │ │ ├── ReportingFilterBar.vue │ │ │ │ │ ├── ReportingGroupBySelect.vue │ │ │ │ │ ├── ReportingOverview.vue │ │ │ │ │ ├── ReportingPieChart.vue │ │ │ │ │ ├── ReportingRoundingControls.vue │ │ │ │ │ ├── ReportingRow.vue │ │ │ │ │ └── ReportingTabNavbar.vue │ │ │ │ ├── StatCard.vue │ │ │ │ ├── TabBar/ │ │ │ │ │ ├── TabBar.vue │ │ │ │ │ └── TabBarItem.vue │ │ │ │ ├── TableHeading.vue │ │ │ │ ├── Tag/ │ │ │ │ │ ├── TagEditModal.vue │ │ │ │ │ ├── TagMoreOptionsDropdown.vue │ │ │ │ │ ├── TagTable.vue │ │ │ │ │ ├── TagTableHeading.vue │ │ │ │ │ └── TagTableRow.vue │ │ │ │ ├── Task/ │ │ │ │ │ ├── TaskCreateModal.vue │ │ │ │ │ ├── TaskEditModal.vue │ │ │ │ │ ├── TaskMoreOptionsDropdown.vue │ │ │ │ │ ├── TaskMultiselectDropdown.vue │ │ │ │ │ ├── TaskTable.vue │ │ │ │ │ ├── TaskTableHeading.vue │ │ │ │ │ └── TaskTableRow.vue │ │ │ │ ├── UpgradeBadge.vue │ │ │ │ ├── UpgradeModal.vue │ │ │ │ └── User/ │ │ │ │ └── UserTimezoneMismatchModal.vue │ │ │ ├── ConfirmationModal.vue │ │ │ ├── ConfirmsPassword.vue │ │ │ ├── CurrentSidebarTimer.vue │ │ │ ├── Dashboard/ │ │ │ │ ├── ActivityGraphCard.vue │ │ │ │ ├── DashboardCard.vue │ │ │ │ ├── DayOverviewCardChart.vue │ │ │ │ ├── DayOverviewCardEntry.vue │ │ │ │ ├── LastSevenDaysCard.vue │ │ │ │ ├── ProjectsChartCard.vue │ │ │ │ ├── RecentlyTrackedTasksCard.vue │ │ │ │ ├── RecentlyTrackedTasksCardEntry.vue │ │ │ │ ├── TeamActivityCard.vue │ │ │ │ ├── TeamActivityCardEntry.vue │ │ │ │ ├── ThisWeekOverview.vue │ │ │ │ └── ThisWeekReportingTable.vue │ │ │ ├── DropdownLink.vue │ │ │ ├── FormSection.vue │ │ │ ├── NavLink.vue │ │ │ ├── NavigationSidebarItem.vue │ │ │ ├── NavigationSidebarLink.vue │ │ │ ├── NotificationContainer.vue │ │ │ ├── OrganizationSwitcher.vue │ │ │ ├── ResponsiveNavLink.vue │ │ │ ├── SectionBorder.vue │ │ │ ├── SectionTitle.vue │ │ │ ├── TableRow.vue │ │ │ ├── TimeTracker.vue │ │ │ ├── UpdateSidebarNotification.vue │ │ │ ├── UserSettingsIcon.vue │ │ │ └── ui/ │ │ │ ├── alert-dialog/ │ │ │ │ ├── AlertDialog.vue │ │ │ │ ├── AlertDialogAction.vue │ │ │ │ ├── AlertDialogCancel.vue │ │ │ │ ├── AlertDialogContent.vue │ │ │ │ ├── AlertDialogDescription.vue │ │ │ │ ├── AlertDialogFooter.vue │ │ │ │ ├── AlertDialogHeader.vue │ │ │ │ ├── AlertDialogTitle.vue │ │ │ │ ├── AlertDialogTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── calendar/ │ │ │ │ ├── Calendar.vue │ │ │ │ ├── CalendarCell.vue │ │ │ │ ├── CalendarCellTrigger.vue │ │ │ │ ├── CalendarDateInput.vue │ │ │ │ ├── CalendarGrid.vue │ │ │ │ ├── CalendarGridBody.vue │ │ │ │ ├── CalendarGridHead.vue │ │ │ │ ├── CalendarGridRow.vue │ │ │ │ ├── CalendarHeadCell.vue │ │ │ │ ├── CalendarHeader.vue │ │ │ │ ├── CalendarHeading.vue │ │ │ │ ├── CalendarNextButton.vue │ │ │ │ ├── CalendarPrevButton.vue │ │ │ │ └── index.ts │ │ │ ├── dialog/ │ │ │ │ ├── Dialog.vue │ │ │ │ ├── DialogClose.vue │ │ │ │ ├── DialogContent.vue │ │ │ │ ├── DialogDescription.vue │ │ │ │ ├── DialogFooter.vue │ │ │ │ ├── DialogHeader.vue │ │ │ │ ├── DialogScrollContent.vue │ │ │ │ ├── DialogTitle.vue │ │ │ │ ├── DialogTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── dropdown-menu/ │ │ │ │ ├── DropdownMenu.vue │ │ │ │ ├── DropdownMenuCheckboxItem.vue │ │ │ │ ├── DropdownMenuContent.vue │ │ │ │ ├── DropdownMenuGroup.vue │ │ │ │ ├── DropdownMenuItem.vue │ │ │ │ ├── DropdownMenuLabel.vue │ │ │ │ ├── DropdownMenuRadioGroup.vue │ │ │ │ ├── DropdownMenuRadioItem.vue │ │ │ │ ├── DropdownMenuSeparator.vue │ │ │ │ ├── DropdownMenuShortcut.vue │ │ │ │ ├── DropdownMenuSub.vue │ │ │ │ ├── DropdownMenuSubContent.vue │ │ │ │ ├── DropdownMenuSubTrigger.vue │ │ │ │ ├── DropdownMenuTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── label/ │ │ │ │ ├── Label.vue │ │ │ │ └── index.ts │ │ │ ├── number-field/ │ │ │ │ ├── NumberField.vue │ │ │ │ ├── NumberFieldContent.vue │ │ │ │ ├── NumberFieldDecrement.vue │ │ │ │ ├── NumberFieldIncrement.vue │ │ │ │ ├── NumberFieldInput.vue │ │ │ │ └── index.ts │ │ │ ├── select/ │ │ │ │ ├── Select.vue │ │ │ │ ├── SelectContent.vue │ │ │ │ ├── SelectGroup.vue │ │ │ │ ├── SelectItem.vue │ │ │ │ ├── SelectItemText.vue │ │ │ │ ├── SelectLabel.vue │ │ │ │ ├── SelectScrollDownButton.vue │ │ │ │ ├── SelectScrollUpButton.vue │ │ │ │ ├── SelectSeparator.vue │ │ │ │ ├── SelectTrigger.vue │ │ │ │ ├── SelectValue.vue │ │ │ │ └── index.ts │ │ │ ├── switch/ │ │ │ │ ├── Switch.vue │ │ │ │ └── index.ts │ │ │ ├── table/ │ │ │ │ ├── Table.vue │ │ │ │ ├── TableBody.vue │ │ │ │ ├── TableCaption.vue │ │ │ │ ├── TableCell.vue │ │ │ │ ├── TableEmpty.vue │ │ │ │ ├── TableFooter.vue │ │ │ │ ├── TableHead.vue │ │ │ │ ├── TableHeader.vue │ │ │ │ ├── TableRow.vue │ │ │ │ └── index.ts │ │ │ └── tabs/ │ │ │ ├── Tabs.vue │ │ │ ├── TabsContent.vue │ │ │ ├── TabsList.vue │ │ │ ├── TabsTrigger.vue │ │ │ └── index.ts │ │ ├── Layouts/ │ │ │ └── AppLayout.vue │ │ ├── Pages/ │ │ │ ├── API/ │ │ │ │ ├── Index.vue │ │ │ │ └── Partials/ │ │ │ │ └── ApiTokenManager.vue │ │ │ ├── Auth/ │ │ │ │ ├── ConfirmPassword.vue │ │ │ │ ├── ForgotPassword.vue │ │ │ │ ├── Login.vue │ │ │ │ ├── Register.vue │ │ │ │ ├── ResetPassword.vue │ │ │ │ ├── TwoFactorChallenge.vue │ │ │ │ └── VerifyEmail.vue │ │ │ ├── Calendar.vue │ │ │ ├── Clients.vue │ │ │ ├── Dashboard.vue │ │ │ ├── Import.vue │ │ │ ├── Members.vue │ │ │ ├── PrivacyPolicy.vue │ │ │ ├── Profile/ │ │ │ │ ├── Partials/ │ │ │ │ │ ├── ApiTokensForm.vue │ │ │ │ │ ├── DeleteUserForm.vue │ │ │ │ │ ├── LogoutOtherBrowserSessionsForm.vue │ │ │ │ │ ├── ThemeForm.vue │ │ │ │ │ ├── TwoFactorAuthenticationForm.vue │ │ │ │ │ ├── UpdatePasswordForm.vue │ │ │ │ │ └── UpdateProfileInformationForm.vue │ │ │ │ └── Show.vue │ │ │ ├── ProjectShow.vue │ │ │ ├── Projects.vue │ │ │ ├── Reporting.vue │ │ │ ├── ReportingDetailed.vue │ │ │ ├── ReportingShared.vue │ │ │ ├── SharedReport.vue │ │ │ ├── Tags.vue │ │ │ ├── Teams/ │ │ │ │ ├── Create.vue │ │ │ │ ├── Partials/ │ │ │ │ │ ├── CreateTeamForm.vue │ │ │ │ │ ├── DeleteTeamForm.vue │ │ │ │ │ ├── ExportData.vue │ │ │ │ │ ├── ImportData.vue │ │ │ │ │ ├── OrganizationBillableRate.vue │ │ │ │ │ ├── OrganizationFormatSettings.vue │ │ │ │ │ ├── OrganizationTimeEntrySettings.vue │ │ │ │ │ ├── TeamMemberManager.vue │ │ │ │ │ └── UpdateTeamNameForm.vue │ │ │ │ └── Show.vue │ │ │ ├── TermsOfService.vue │ │ │ ├── Time.vue │ │ │ └── Welcome.vue │ │ ├── app.ts │ │ ├── bootstrap.js │ │ ├── lib/ │ │ │ └── utils.ts │ │ ├── packages/ │ │ │ ├── api/ │ │ │ │ ├── .gitignore │ │ │ │ ├── package.json │ │ │ │ ├── src/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── openapi.json.client.ts │ │ │ │ ├── tsconfig.json │ │ │ │ └── vite.config.js │ │ │ └── ui/ │ │ │ ├── .gitignore │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── Badge.vue │ │ │ │ ├── BillableRateModal.vue │ │ │ │ ├── Buttons/ │ │ │ │ │ ├── Button.vue │ │ │ │ │ ├── DangerButton.vue │ │ │ │ │ ├── PrimaryButton.vue │ │ │ │ │ ├── SecondaryButton.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── CardTitle.vue │ │ │ │ ├── Client/ │ │ │ │ │ ├── ClientDropdown.vue │ │ │ │ │ └── ClientDropdownItem.vue │ │ │ │ ├── CommandPalette/ │ │ │ │ │ ├── CommandPalette.vue │ │ │ │ │ ├── CommandPaletteTypes.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── DialogModal.vue │ │ │ │ ├── EstimatedTimeProgress.vue │ │ │ │ ├── EstimatedTimeSection.vue │ │ │ │ ├── FullCalendar/ │ │ │ │ │ ├── CalendarSettingsPopover.vue │ │ │ │ │ ├── FullCalendarDayHeader.vue │ │ │ │ │ ├── FullCalendarEventContent.vue │ │ │ │ │ ├── TimeEntryCalendar.vue │ │ │ │ │ ├── calendarSettings.ts │ │ │ │ │ ├── idleStatusPlugin.ts │ │ │ │ │ └── useVisualSnap.ts │ │ │ │ ├── GroupedItemsCountButton.vue │ │ │ │ ├── Icons/ │ │ │ │ │ ├── BillableIcon.vue │ │ │ │ │ ├── DollarIcon.vue │ │ │ │ │ ├── EuroIcon.vue │ │ │ │ │ └── ListFilterIcon.vue │ │ │ │ ├── Input/ │ │ │ │ │ ├── BillableRateInput.vue │ │ │ │ │ ├── BillableToggleButton.vue │ │ │ │ │ ├── Checkbox.vue │ │ │ │ │ ├── DatePicker.vue │ │ │ │ │ ├── DateRangePicker.vue │ │ │ │ │ ├── Dropdown.vue │ │ │ │ │ ├── DurationHumanInput.vue │ │ │ │ │ ├── EstimatedTimeInput.vue │ │ │ │ │ ├── InputError.vue │ │ │ │ │ ├── InputLabel.vue │ │ │ │ │ ├── MultiselectDropdown.vue │ │ │ │ │ ├── TextInput.vue │ │ │ │ │ ├── TextareaInput.vue │ │ │ │ │ ├── TimePickerSimple.vue │ │ │ │ │ └── TimeRangeSelector.vue │ │ │ │ ├── LoadingSpinner.vue │ │ │ │ ├── MainContainer.vue │ │ │ │ ├── Modal.vue │ │ │ │ ├── Project/ │ │ │ │ │ ├── ProjectBadge.vue │ │ │ │ │ ├── ProjectBillableRateModal.vue │ │ │ │ │ ├── ProjectBillableSelect.vue │ │ │ │ │ ├── ProjectColorSelector.vue │ │ │ │ │ ├── ProjectCreateModal.vue │ │ │ │ │ ├── ProjectDropdownItem.vue │ │ │ │ │ └── ProjectEditBillableSection.vue │ │ │ │ ├── Tag/ │ │ │ │ │ ├── TagBadge.vue │ │ │ │ │ ├── TagCreateModal.vue │ │ │ │ │ └── TagDropdown.vue │ │ │ │ ├── TimeEntry/ │ │ │ │ │ ├── TimeEntryAggregateRow.vue │ │ │ │ │ ├── TimeEntryCreateModal.vue │ │ │ │ │ ├── TimeEntryDescriptionInput.vue │ │ │ │ │ ├── TimeEntryEditModal.vue │ │ │ │ │ ├── TimeEntryGroupedTable.vue │ │ │ │ │ ├── TimeEntryMassActionRow.vue │ │ │ │ │ ├── TimeEntryMassUpdateModal.vue │ │ │ │ │ ├── TimeEntryMoreOptionsDropdown.vue │ │ │ │ │ ├── TimeEntryRangeSelector.vue │ │ │ │ │ ├── TimeEntryRow.vue │ │ │ │ │ ├── TimeEntryRowDurationInput.vue │ │ │ │ │ ├── TimeEntryRowHeading.vue │ │ │ │ │ └── TimeEntryRowTagDropdown.vue │ │ │ │ ├── TimeTracker/ │ │ │ │ │ ├── TimeTrackerControls.vue │ │ │ │ │ ├── TimeTrackerMoreOptionsDropdown.vue │ │ │ │ │ ├── TimeTrackerProjectTaskDropdown.vue │ │ │ │ │ ├── TimeTrackerRangeSelector.vue │ │ │ │ │ ├── TimeTrackerRecentlyTrackedEntry.vue │ │ │ │ │ ├── TimeTrackerRunningInDifferentOrganizationOverlay.vue │ │ │ │ │ └── TimeTrackerTagDropdown.vue │ │ │ │ ├── TimeTrackerStartStop.vue │ │ │ │ ├── TimezoneMismatchModal.vue │ │ │ │ ├── accordion/ │ │ │ │ │ ├── Accordion.vue │ │ │ │ │ ├── AccordionContent.vue │ │ │ │ │ ├── AccordionItem.vue │ │ │ │ │ ├── AccordionTrigger.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── command/ │ │ │ │ │ ├── Command.vue │ │ │ │ │ ├── CommandGroup.vue │ │ │ │ │ ├── CommandInput.vue │ │ │ │ │ ├── CommandItem.vue │ │ │ │ │ ├── CommandList.vue │ │ │ │ │ ├── CommandSeparator.vue │ │ │ │ │ ├── CommandShortcut.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── field/ │ │ │ │ │ ├── Field.vue │ │ │ │ │ ├── FieldContent.vue │ │ │ │ │ ├── FieldDescription.vue │ │ │ │ │ ├── FieldError.vue │ │ │ │ │ ├── FieldGroup.vue │ │ │ │ │ ├── FieldLabel.vue │ │ │ │ │ ├── FieldLegend.vue │ │ │ │ │ ├── FieldSeparator.vue │ │ │ │ │ ├── FieldSet.vue │ │ │ │ │ ├── FieldTitle.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── popover/ │ │ │ │ │ ├── Popover.vue │ │ │ │ │ ├── PopoverContent.vue │ │ │ │ │ ├── PopoverTrigger.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── range-calendar/ │ │ │ │ │ ├── RangeCalendar.vue │ │ │ │ │ ├── RangeCalendarCell.vue │ │ │ │ │ ├── RangeCalendarCellTrigger.vue │ │ │ │ │ ├── RangeCalendarGrid.vue │ │ │ │ │ ├── RangeCalendarGridBody.vue │ │ │ │ │ ├── RangeCalendarGridHead.vue │ │ │ │ │ ├── RangeCalendarGridRow.vue │ │ │ │ │ ├── RangeCalendarHeadCell.vue │ │ │ │ │ ├── RangeCalendarHeader.vue │ │ │ │ │ ├── RangeCalendarHeading.vue │ │ │ │ │ ├── RangeCalendarNextButton.vue │ │ │ │ │ ├── RangeCalendarPrevButton.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── separator/ │ │ │ │ │ ├── Separator.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── tooltip/ │ │ │ │ │ ├── Tooltip.vue │ │ │ │ │ ├── TooltipContent.vue │ │ │ │ │ ├── TooltipProvider.vue │ │ │ │ │ ├── TooltipTrigger.vue │ │ │ │ │ └── index.ts │ │ │ │ └── utils/ │ │ │ │ ├── cn.ts │ │ │ │ ├── color.ts │ │ │ │ ├── money.ts │ │ │ │ ├── number.ts │ │ │ │ ├── random.ts │ │ │ │ ├── select.ts │ │ │ │ ├── settings.ts │ │ │ │ └── time.ts │ │ │ ├── styles.css │ │ │ ├── tailwind.theme.js │ │ │ ├── tsconfig.json │ │ │ └── vite.config.js │ │ ├── types/ │ │ │ ├── dom.d.ts │ │ │ ├── dom.ts │ │ │ ├── global.d.ts │ │ │ ├── inertia.d.ts │ │ │ ├── jetstream.ts │ │ │ ├── models.d.ts │ │ │ ├── models.ts │ │ │ ├── projects.d.ts │ │ │ ├── reporting.ts │ │ │ ├── time-entries.d.ts │ │ │ ├── vite-env.d.ts │ │ │ └── vue-shim.d.ts │ │ ├── utils/ │ │ │ ├── billing.ts │ │ │ ├── commandPaletteCommands.ts │ │ │ ├── feedback.ts │ │ │ ├── fetchAllPages.ts │ │ │ ├── format.ts │ │ │ ├── init.ts │ │ │ ├── money.ts │ │ │ ├── notification.ts │ │ │ ├── permissions.ts │ │ │ ├── prefetch.ts │ │ │ ├── roles.ts │ │ │ ├── session.ts │ │ │ ├── theme.ts │ │ │ ├── useAggregatedTimeEntriesQuery.ts │ │ │ ├── useClients.ts │ │ │ ├── useClientsQuery.ts │ │ │ ├── useCommandPalette.ts │ │ │ ├── useCssVariable.ts │ │ │ ├── useCurrentTimeEntry.ts │ │ │ ├── useInvitations.ts │ │ │ ├── useMembers.ts │ │ │ ├── useMembersQuery.ts │ │ │ ├── useOrganization.ts │ │ │ ├── useOrganizationQuery.ts │ │ │ ├── useProjectMembers.ts │ │ │ ├── useProjectMembersQuery.ts │ │ │ ├── useProjects.ts │ │ │ ├── useProjectsQuery.ts │ │ │ ├── useReporting.ts │ │ │ ├── useReportsQuery.ts │ │ │ ├── useTags.ts │ │ │ ├── useTagsQuery.ts │ │ │ ├── useTasks.ts │ │ │ ├── useTasksQuery.ts │ │ │ ├── useTimeEntriesCalendarQuery.ts │ │ │ ├── useTimeEntriesInfiniteQuery.ts │ │ │ ├── useTimeEntriesMutations.ts │ │ │ ├── useTimeEntriesReportQuery.ts │ │ │ └── useUser.ts │ │ ├── ziggy.d.ts │ │ └── ziggy.js │ ├── markdown/ │ │ ├── policy.md │ │ └── terms.md │ ├── testfiles/ │ │ ├── clockify_projects_import_test_1.csv │ │ ├── clockify_time_entries_import_test_1.csv │ │ ├── clockify_time_entries_import_test_2.csv │ │ ├── clockify_time_entries_import_test_3.csv │ │ ├── generic_projects_import_test_1.csv │ │ ├── generic_time_entries_import_test_1.csv │ │ ├── harvest_clients_import_test_1.csv │ │ ├── harvest_projects_import_test_1.csv │ │ ├── harvest_time_entries_import_test_1.csv │ │ ├── solidtime_import_test_1/ │ │ │ ├── clients.csv │ │ │ ├── members.csv │ │ │ ├── meta.json │ │ │ ├── organization_invitations.csv │ │ │ ├── organizations.csv │ │ │ ├── project_members.csv │ │ │ ├── projects.csv │ │ │ ├── tags.csv │ │ │ ├── tasks.csv │ │ │ └── time_entries.csv │ │ ├── toggl_data_import_test_1/ │ │ │ ├── clients.json │ │ │ ├── projects.json │ │ │ ├── projects_users/ │ │ │ │ ├── 401.json │ │ │ │ ├── 402.json │ │ │ │ └── 403.json │ │ │ ├── tags.json │ │ │ ├── tasks/ │ │ │ │ ├── 401.json │ │ │ │ ├── 402.json │ │ │ │ └── 403.json │ │ │ └── workspace_users.json │ │ ├── toggl_data_import_test_2/ │ │ │ ├── clients.json │ │ │ ├── projects.json │ │ │ ├── projects_users/ │ │ │ │ ├── 401.json │ │ │ │ ├── 402.json │ │ │ │ └── 403.json │ │ │ ├── tags.json │ │ │ ├── tasks/ │ │ │ │ ├── 401.json │ │ │ │ ├── 402.json │ │ │ │ └── 403.json │ │ │ └── workspace_users.json │ │ ├── toggl_time_entries_import_test_1.csv │ │ └── toggl_time_entries_import_test_2.csv │ └── views/ │ ├── app.blade.php │ ├── auth/ │ │ └── oauth/ │ │ └── authorize.blade.php │ ├── emails/ │ │ ├── auth-api-expiration-reminder.blade.php │ │ ├── auth-api-token-expired.blade.php │ │ ├── organization-invitation.blade.php │ │ └── time-entry-still-running.blade.php │ ├── filament/ │ │ └── widgets/ │ │ └── server-overview.blade.php │ ├── reports/ │ │ ├── time-entry-aggregate/ │ │ │ ├── pdf-footer.blade.php │ │ │ ├── pdf.blade.php │ │ │ └── spreadsheet.blade.php │ │ └── time-entry-index/ │ │ ├── pdf-footer.blade.php │ │ └── pdf.blade.php │ └── vendor/ │ └── mail/ │ ├── html/ │ │ ├── button.blade.php │ │ ├── footer.blade.php │ │ ├── header.blade.php │ │ ├── layout.blade.php │ │ ├── message.blade.php │ │ ├── panel.blade.php │ │ ├── subcopy.blade.php │ │ ├── table.blade.php │ │ └── themes/ │ │ └── default.css │ └── text/ │ ├── button.blade.php │ ├── footer.blade.php │ ├── header.blade.php │ ├── layout.blade.php │ ├── message.blade.php │ ├── panel.blade.php │ ├── subcopy.blade.php │ └── table.blade.php ├── routes/ │ ├── api.php │ └── web.php ├── storage/ │ ├── app/ │ │ └── .gitignore │ ├── framework/ │ │ ├── .gitignore │ │ ├── cache/ │ │ │ └── .gitignore │ │ ├── sessions/ │ │ │ └── .gitignore │ │ ├── testing/ │ │ │ └── .gitignore │ │ └── views/ │ │ └── .gitignore │ └── logs/ │ └── .gitignore ├── tailwind.config.js ├── tests/ │ ├── CreatesApplication.php │ ├── Feature/ │ │ ├── AuthenticationTest.php │ │ ├── BrowserSessionsTest.php │ │ ├── CreateOrganizationTest.php │ │ ├── DeleteAccountTest.php │ │ ├── DeleteOrganizationTest.php │ │ ├── EmailVerificationTest.php │ │ ├── InviteTeamMemberTest.php │ │ ├── LeaveTeamTest.php │ │ ├── PasswordConfirmationTest.php │ │ ├── PasswordResetTest.php │ │ ├── ProfileInformationTest.php │ │ ├── RegistrationTest.php │ │ ├── RemoveTeamMemberTest.php │ │ ├── TwoFactorAuthenticationSettingsTest.php │ │ ├── UpdatePasswordTest.php │ │ ├── UpdateTeamMemberRoleTest.php │ │ └── UpdateTeamTest.php │ ├── TestCase.php │ ├── TestCaseWithDatabase.php │ └── Unit/ │ ├── Console/ │ │ ├── Commands/ │ │ │ ├── Admin/ │ │ │ │ ├── OrganizationDeleteCommandTest.php │ │ │ │ ├── UserCreateCommandCommandTest.php │ │ │ │ └── UserVerifyCommandTest.php │ │ │ ├── Auth/ │ │ │ │ └── AuthSendReminderForExpiringApiTokensCommandTest.php │ │ │ ├── Correction/ │ │ │ │ └── CorrectionPlaceholderMembersCommandTest.php │ │ │ ├── Report/ │ │ │ │ └── ReportSetExpiredToPrivateCommandTest.php │ │ │ ├── SelfHost/ │ │ │ │ ├── SelfHostCheckForUpdateCommandTest.php │ │ │ │ ├── SelfHostDatabaseConsistencyCommandTest.php │ │ │ │ ├── SelfHostGenerateKeysCommandTest.php │ │ │ │ └── SelfHostTelemetryCommandTest.php │ │ │ └── TimeEntry/ │ │ │ └── TimeEntrySendStillRunningMailsCommandTest.php │ │ └── KernelTest.php │ ├── Database/ │ │ ├── MigrationTest.php │ │ └── SeederTest.php │ ├── Endpoint/ │ │ ├── Api/ │ │ │ └── V1/ │ │ │ ├── ApiEndpointTestAbstract.php │ │ │ ├── ApiTokenEndpointTest.php │ │ │ ├── ChartEndpointTest.php │ │ │ ├── ClientEndpointTest.php │ │ │ ├── CurrencyEndpointTest.php │ │ │ ├── ExportEndpointTest.php │ │ │ ├── ImportEndpointTest.php │ │ │ ├── InvitationEndpointTest.php │ │ │ ├── MemberEndpointTest.php │ │ │ ├── OrganizationEndpointTest.php │ │ │ ├── ProjectEndpointTest.php │ │ │ ├── ProjectMemberEndpointTest.php │ │ │ ├── Public/ │ │ │ │ └── PublicReportEndpointTest.php │ │ │ ├── ReportEndpointTest.php │ │ │ ├── TagEndpointTest.php │ │ │ ├── TaskEndpointTest.php │ │ │ ├── TimeEntryEndpointTest.php │ │ │ ├── UserEndpointTest.php │ │ │ ├── UserMembershipEndpointTest.php │ │ │ └── UserTimeEntryEndpointTest.php │ │ └── Web/ │ │ ├── DashboardEndpointTest.php │ │ ├── EndpointTestAbstract.php │ │ ├── HealthCheckEndpointTest.php │ │ └── HomeEndpointTest.php │ ├── Filament/ │ │ ├── FilamentTestCase.php │ │ ├── Resources/ │ │ │ ├── AuditResourceTest.php │ │ │ ├── ClientResourceTest.php │ │ │ ├── FailedJobResourceTest.php │ │ │ ├── OrganizationInvitationResourceTest.php │ │ │ ├── OrganizationResourceTest.php │ │ │ ├── ProjectResourceTest.php │ │ │ ├── ReportResourceTest.php │ │ │ ├── TagResourceTest.php │ │ │ ├── TaskResourceTest.php │ │ │ ├── TimeEntryResourceTest.php │ │ │ ├── TokenResourceTest.php │ │ │ └── UserResourceTest.php │ │ └── Widgets/ │ │ └── ServerOverviewWidgetTest.php │ ├── Jobs/ │ │ ├── RecalculateSpentTimeForProjectTest.php │ │ ├── RecalculateSpentTimeForTaskTest.php │ │ └── Test/ │ │ └── TestJobTest.php │ ├── Mail/ │ │ ├── AuthApiTokenExpirationReminderMailTest.php │ │ ├── AuthApiTokenExpiredMailTest.php │ │ ├── OrganizationInvitationMailTest.php │ │ └── TimeEntryStillRunningMailTest.php │ ├── Middleware/ │ │ ├── CheckOrganizationBlockedMiddlewareTest.php │ │ ├── EnsureEmailIsVerifiedMiddlewareTest.php │ │ ├── ForceHttpsMiddlewareTest.php │ │ ├── HandleInertiaRequestsMiddlewareTest.php │ │ └── MiddlewareTestAbstract.php │ ├── Model/ │ │ ├── ClientModelTest.php │ │ ├── MemberModelTest.php │ │ ├── ModelTestAbstract.php │ │ ├── OrganizationModelTest.php │ │ ├── Passport/ │ │ │ └── TokenModelTest.php │ │ ├── ProjectMemberModelTest.php │ │ ├── ProjectModelTest.php │ │ ├── ReportModelTest.php │ │ ├── TagModelTest.php │ │ ├── TaskModelTest.php │ │ ├── TimeEntryModelTest.php │ │ └── UserModelTest.php │ ├── Rules/ │ │ ├── ColorRuleTest.php │ │ └── CurrencyRuleTest.php │ └── Service/ │ ├── BillableRateServiceTest.php │ ├── CurrencyServiceTest.php │ ├── DashboardServiceTest.php │ ├── DeletionServiceTest.php │ ├── Export/ │ │ └── ExportServiceTest.php │ ├── Import/ │ │ ├── ImportDatabaseHelperTest.php │ │ ├── ImportServiceTest.php │ │ └── Importers/ │ │ ├── ClockifyProjectsImporterTest.php │ │ ├── ClockifyTimeEntriesImporterTest.php │ │ ├── GenericProjectsImporterTest.php │ │ ├── GenericTimeEntriesImporterTest.php │ │ ├── HarvestClientsImporterTest.php │ │ ├── HarvestProjectsImporterTest.php │ │ ├── HarvestTimeEntriesImporterTest.php │ │ ├── ImporterProviderTest.php │ │ ├── ImporterTestAbstract.php │ │ ├── SolidtimeImporterTest.php │ │ ├── TogglDataImporterTest.php │ │ └── TogglTimeEntriesImporterTest.php │ ├── IntervalServiceTest.php │ ├── LocalizationServiceTest.php │ ├── MemberServiceTest.php │ ├── PermissionStoreTest.php │ ├── TimeEntryAggregationServiceTest.php │ ├── TimeEntryFilterTest.php │ ├── TimezoneServiceTest.php │ └── UserServiceTest.php ├── tsconfig.json ├── vite-module-loader.js └── vite.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false [*.{yml,yaml}] indent_size = 2 [docker-compose.yml] indent_size = 4 ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf *.blade.php diff=html *.css diff=css *.html diff=html *.md diff=markdown *.php diff=php /.github export-ignore CHANGELOG.md export-ignore .styleci.yml export-ignore ================================================ FILE: .github/FUNDING.yml ================================================ github: solidtime-io ================================================ FILE: .github/ISSUE_TEMPLATE/1_bug_report.yml ================================================ name: Bug Report description: "Report a bug" body: - type: markdown attributes: value: | Before creating a new bug report, please check that there isn't already a similar issue. - type: textarea attributes: label: Description description: A clear and concise description of what the bug is. validations: required: true - type: textarea attributes: label: "Steps To Reproduce" description: How do you trigger this bug? Please walk us through it step by step. value: | 1. 2. 3. ... validations: required: false - type: dropdown attributes: label: "Self-hosted or Cloud?" options: - Self-Hosted - solidtime Cloud - Both - type: input attributes: label: "Version of solidtime: (for self-hosted)" validations: required: false - type: input attributes: label: "solidtime self-hosting guide: (for self-hosted)" description: "Did you use the official guide to self-host solidtime? If yes, which one?" validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 🚀 Feature Request url: https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests about: Share ideas for new features - name: ❓ Ask a Question url: https://github.com/solidtime-io/solidtime/discussions/new?category=general about: Ask the community for help ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## What does this PR do? - Fixes #XXXX (GitHub issue number) ## Checklist (DO NOT REMOVE) - [ ] I read the [contributing guide](https://github.com/solidtime-io/solidtime/blob/main/CONTRIBUTING.md) - [ ] I signed the [Contributor License Agreement](https://cla-assistant.io/solidtime-io/solidtime). - [ ] I commented my code, particularly in hard-to-understand areas ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" target-branch: "main" - package-ecosystem: "docker" directory: "/" schedule: interval: "daily" target-branch: "main" - package-ecosystem: "composer" directory: "/" schedule: interval: "weekly" target-branch: "main" groups: major-updates: update-types: - "major" minor-updates: update-types: - "minor" - "patch" security-updates: applies-to: version-updates update-types: - "minor" - "patch" - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" target-branch: "main" groups: major-updates: update-types: - "major" minor-updates: update-types: - "minor" - "patch" security-updates: applies-to: version-updates update-types: - "minor" - "patch" ================================================ FILE: .github/workflows/build-onpremise.yml ================================================ 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 }} ================================================ FILE: .github/workflows/build-private.yml ================================================ on: push: branches: - main - develop tags: - '*' pull_request: paths: - '.github/workflows/build-private.yml' - 'docker/prod/**' workflow_dispatch: permissions: contents: read name: Build - Private jobs: build: runs-on: ubuntu-latest timeout-minutes: 20 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: 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: 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.version.outputs.app_version }}/g' .env - name: "Add build to .env" run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env - name: "Output .env" run: cat .env - name: "Use Node.js" uses: actions/setup-node@v4 with: node-version: '20.x' - name: "Checkout billing extension" uses: actions/checkout@v4 with: repository: solidtime-io/extension-billing path: extensions/Billing ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }} - name: "Install dependencies in billing extension" uses: php-actions/composer@v6 env: COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}' with: working_dir: "extensions/Billing" command: install only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative php_version: 8.3 - name: "Install npm dependencies in billing extension" run: cd extensions/Billing && npm ci - name: "Checkout services extension" uses: actions/checkout@v4 with: repository: solidtime-io/extension-services path: extensions/Services ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }} - name: "Install composer dependencies in services extension" uses: php-actions/composer@v6 with: working_dir: "extensions/Services" command: install only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative php_version: 8.3 - name: "Install npm dependencies in services extension" run: cd extensions/Services && npm ci - 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" uses: php-actions/composer@v6 with: working_dir: "extensions/Invoicing" command: install only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative php_version: 8.3 - name: "Install npm dependencies in invoicing extension" run: cd extensions/Invoicing && npm ci - name: "Setup PHP with PECL extension" uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: mbstring, dom, fileinfo, pgsql - name: "Install dependencies" uses: php-actions/composer@v6 if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit with: command: install only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative php_version: 8.3 - name: "Activate billing extension" run: php artisan module:enable Billing - name: "Activate services extension" run: php artisan module:enable Services - name: "Activate invoicing extension" run: php artisan module:enable Invoicing - name: "Install npm dependencies" run: npm ci - name: "Build" run: npm run build env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: "Login to GitHub Container Registry" uses: docker/login-action@v3 with: registry: rg.fr-par.scw.cloud/solidtime username: nologin password: ${{ secrets.SCALEWAY_REGISTRY_TOKEN }} - name: "Docker meta" id: "meta" uses: docker/metadata-action@v5 with: images: rg.fr-par.scw.cloud/solidtime/solidtime tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,format=long - name: "Set up QEMU" uses: docker/setup-qemu-action@v3 - name: "Set up Docker Buildx" uses: docker/setup-buildx-action@v3 - name: "Build and push" uses: docker/build-push-action@v6 with: context: . build-args: | DOCKER_FILES_BASE_PATH=docker/prod/ file: docker/prod/Dockerfile push: true platforms: linux/amd64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max ================================================ FILE: .github/workflows/build-public.yml ================================================ on: push: branches: - main - develop tags: - '*' pull_request: paths: - '.github/workflows/build-public.yml' - 'docker/prod/**' workflow_dispatch: permissions: packages: write contents: read attestations: write id-token: write env: DOCKERHUB_REPO: solidtime/solidtime GHCR_REPO: ghcr.io/solidtime-io/solidtime name: Build - Public 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: "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.DOCKERHUB_REPO }} ${{ env.GHCR_REPO }} - name: "Login to Docker Hub Container Registry" uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: "Login to GitHub Container Registry" uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: "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.DOCKERHUB_REPO }},${{ env.GHCR_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 Docker Hub" uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: "Login to GHCR" uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_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.DOCKERHUB_REPO }} ${{ env.GHCR_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.DOCKERHUB_REPO }}@sha256:%s ' *) docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) - name: "Inspect image" run: | docker buildx imagetools inspect ${{ env.DOCKERHUB_REPO }}:${{ steps.meta.outputs.version }} docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }} ================================================ FILE: .github/workflows/generate-api-docs.yml ================================================ name: Generate API docs on: push: branches: - main permissions: contents: read jobs: api_docs: runs-on: ubuntu-latest timeout-minutes: 10 services: pgsql_test: image: postgres:15 env: PGPASSWORD: 'root' POSTGRES_DB: 'laravel' POSTGRES_USER: 'root' POSTGRES_PASSWORD: 'root' ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: "Checkout code" uses: actions/checkout@v4 - name: "Setup PHP" uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv - name: "Run composer install" run: composer install -n --prefer-dist - name: "Create build directory" run: mkdir build - name: Prepare Laravel Application run: | cp .env.ci .env php artisan migrate - name: "Export API docs" run: php artisan scramble:export --path=build/api-docs.json - name: "Upload API docs to GitHub" uses: actions/upload-artifact@v4 with: name: api-docs.json path: build/api-docs.json - name: "Download Fastfront CLI" run: curl https://fastfront-cli.s3.fr-par.scw.cloud/fastfront-cli.phar -o fastfront-cli.phar - name: "Deploy with Fastfront" run: php fastfront-cli.phar deploy 9beab6cf-f459-446b-85f1-38ec007cf457 ./build env: FASTFRONT_API_KEY: ${{ secrets.FASTFRONT_API_DOCS_API_KEY }} ================================================ FILE: .github/workflows/npm-build.yml ================================================ name: NPM Build on: [push] permissions: contents: read jobs: build: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: "Checkout code" uses: actions/checkout@v4 - name: "Setup PHP (for Ziggy)" uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: intl, zip coverage: none - name: "Run composer install (for Ziggy)" run: composer install -n --prefer-dist - name: "Use Node.js" uses: actions/setup-node@v4 with: node-version: '20.x' - name: "Install npm dependencies" run: npm ci - name: "Build" run: npm run build ================================================ FILE: .github/workflows/npm-format-check.yml ================================================ 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 ================================================ FILE: .github/workflows/npm-lint.yml ================================================ name: NPM Lint on: [push] permissions: contents: read jobs: build: 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: "Run linter" run: npm run lint ================================================ FILE: .github/workflows/npm-publish-api.yml ================================================ name: Publish API package to NPM on: workflow_dispatch permissions: contents: read jobs: build: runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - name: "Checkout code" uses: actions/checkout@v4 # Setup .npmrc file to publish to npm - name: Install root project dependencies run: npm ci - uses: actions/setup-node@v4 with: node-version: '20.x' registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm ci working-directory: ./resources/js/packages/api - name: Build package run: npm run build working-directory: ./resources/js/packages/api - name: Publish Package run: npm publish --provenance --access public working-directory: ./resources/js/packages/api env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .github/workflows/npm-publish-ui.yml ================================================ name: Publish UI package to NPM on: workflow_dispatch permissions: contents: read jobs: build: runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - name: "Checkout code" uses: actions/checkout@v4 # Setup .npmrc file to publish to npm - uses: actions/setup-node@v4 with: node-version: '20.x' registry-url: 'https://registry.npmjs.org' - name: Install root project dependencies run: npm ci - name: Install package dependencies run: npm ci working-directory: ./resources/js/packages/ui - name: Build package run: npm run build working-directory: ./resources/js/packages/ui - name: Publish Package run: npm publish --provenance --access public working-directory: ./resources/js/packages/ui env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .github/workflows/npm-typecheck.yml ================================================ name: NPM Typecheck on: [push] permissions: contents: read jobs: build: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: "Checkout code" uses: actions/checkout@v4 - name: "Setup PHP (for Ziggy)" uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: intl, zip coverage: none - name: "Run composer install (for Ziggy)" run: composer install -n --prefer-dist - name: "Use Node.js" uses: actions/setup-node@v4 with: node-version: '20.x' - name: "Install npm dependencies" run: npm ci - name: "Run type check" run: npm run type-check ================================================ FILE: .github/workflows/phpstan.yml ================================================ name: Static code analysis (PHPStan) on: push permissions: contents: read jobs: phpstan: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: "Checkout code" uses: actions/checkout@v4 - name: "Setup PHP" uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv coverage: none - name: "Run composer install" run: composer install -n --prefer-dist - name: "Run PHPStan" run: composer analyse ================================================ FILE: .github/workflows/phpunit.yml ================================================ name: PHPUnit Tests on: push permissions: contents: read jobs: phpunit: runs-on: ubuntu-latest timeout-minutes: 10 strategy: matrix: postgres_version: [ 15, 16, 17 ] services: pgsql_test: image: postgres:${{ matrix.postgres_version }} env: PGPASSWORD: 'root' POSTGRES_DB: 'laravel' POSTGRES_USER: 'root' POSTGRES_PASSWORD: 'root' ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 gotenberg: image: gotenberg/gotenberg:8 ports: - 3000:3000 options: >- --health-cmd "curl --silent --fail http://localhost:3000/health" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: "Checkout code" uses: actions/checkout@v4 - name: "Setup PHP" uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv coverage: pcov - name: "Run composer install" run: composer install -n --prefer-dist - uses: actions/setup-node@v4 with: node-version: '20.x' - name: "Install dependencies" run: npm ci - name: "Build Frontend" run: npm run build - name: "Prepare Laravel Application" run: | cp .env.ci .env php artisan key:generate php artisan passport:keys - name: "Run PHPUnit" run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml - name: "Upload coverage reports to Codecov" uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} slug: solidtime-io/solidtime ================================================ FILE: .github/workflows/pint.yml ================================================ name: PHP Linting on: push permissions: contents: read jobs: pint: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: "Checkout code" uses: actions/checkout@v4 - name: "Check code style" uses: aglipanci/laravel-pint-action@2.5 with: configPath: "pint.json" ================================================ FILE: .github/workflows/playwright.yml ================================================ name: Playwright Tests on: [push] permissions: contents: read jobs: test: runs-on: ubuntu-latest timeout-minutes: 60 strategy: fail-fast: false matrix: shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] shardTotal: [8] services: mailpit: image: 'axllent/mailpit:latest' ports: - 1025:1025 - 8025:8025 pgsql_test: image: postgres:15 env: PGPASSWORD: 'root' POSTGRES_DB: 'laravel' POSTGRES_USER: 'root' POSTGRES_PASSWORD: 'root' ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: "Checkout code" uses: actions/checkout@v4 - name: "Setup node" uses: actions/setup-node@v4 with: node-version: '20.x' - name: "Setup PHP" uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv coverage: none - name: "Run composer install" run: composer install -n --prefer-dist - name: "Prepare Laravel Application" run: | cp .env.ci .env php artisan key:generate php artisan passport:keys php artisan migrate --seed - name: "Install dependencies" run: npm ci - name: "Build Frontend" run: npm run build - name: "Install FrankenPHP" run: | ARCH="$(uname -m)" curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp chmod +x /usr/local/bin/frankenphp - name: "Run Laravel Octane Server" run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 & env: OCTANE_SERVER: frankenphp - name: "Install Playwright Browsers" run: npx playwright install --with-deps - name: "Run Playwright tests" run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} env: PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000' MAILPIT_BASE_URL: 'http://localhost:8025' - name: "Upload blob report" uses: actions/upload-artifact@v4 if: always() with: name: blob-report-${{ matrix.shardIndex }} path: blob-report/ retention-days: 7 merge-reports: if: always() needs: [test] runs-on: ubuntu-latest steps: - name: "Checkout code" uses: actions/checkout@v4 - name: "Setup node" uses: actions/setup-node@v4 with: node-version: '20.x' - name: "Install dependencies" run: npm ci - name: "Download blob reports" uses: actions/download-artifact@v4 with: path: all-blob-reports pattern: blob-report-* merge-multiple: true - name: "Merge reports" run: npx playwright merge-reports --reporter html ./all-blob-reports - name: "Upload merged HTML report" uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report/ retention-days: 30 ================================================ FILE: .gitignore ================================================ /.phpunit.cache node_modules dist /public/build /public/hot /public/storage /public/css /public/js /public/vendor /lang/vendor /storage/*.key /vendor .env .env.backup .phpunit.result.cache Homestead.json Homestead.yaml auth.json npm-debug.log yarn-error.log /.fleet /.idea /.vscode /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ /coverage /extensions !/extensions/.gitkeep !/extensions/extensions_autoload.php /auth.json /modules_statuses.json /k8s /_ide_helper.php /.phpstorm.meta.php /.rnd /caddy /frankenphp /public/frankenphp-worker.php /data /config/caddy /config/composer ================================================ FILE: .prettierignore ================================================ # 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 ================================================ FILE: .prettierrc.json ================================================ { "trailingComma": "es5", "tabWidth": 4, "singleQuote": true, "bracketSameLine": true, "quoteProps": "preserve", "printWidth": 100 } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Code of Conduct The goal is to create a community that is open and welcoming to all individuals. To achieve this, we have developed a code of conduct that outlines the expectations for behavior of all members of our community. ## Pledge This community is founded on respect and understanding. All members are expected to treat others with respect and empathy, and to not tolerate any form of discrimination, harassment, or attacks. ## Expectations Examples of behavior that contributes to creating a positive environment include: - Using welcoming and inclusive language - Being respectful of differing viewpoints and experiences - Gracefully accepting constructive criticism - Focusing on what is best for the community - Showing empathy towards other community members Examples of unacceptable behavior by participants include: - The use of sexualized language or imagery and sexual attention or advances - Trolling, insulting/derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or electronic address, without explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Contact If you feel uncomfortable or believe that someone has violated the code of conduct, please contact us at [hello@solidtime.io](mailto:hello@solidtime.io). We will thoroughly investigate the incident and aim for the best possible outcome. ================================================ FILE: CONTRIBUTING.md ================================================ # 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 we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t 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. That’s why we’ve 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 solidtime’s 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. We’ll also provide proprietary extensions for solidtime. These will be available to enterprise customers with support plans, but also to individual users or teams who don’t 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 can’t or don’t 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 code’s 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. ================================================ FILE: LICENSE.md ================================================ GNU Affero General Public License ================================= _Version 3, 19 November 2007_ _Copyright © 2007 Free Software Foundation, Inc. <>_ Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. ## Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: **(1)** assert copyright on the software, and **(2)** offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. ## TERMS AND CONDITIONS ### 0. Definitions “This License” refers to version 3 of the GNU Affero General Public License. “Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. “The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. A “covered work” means either the unmodified Program or a work based on the Program. To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that **(1)** displays an appropriate copyright notice, and **(2)** tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. ### 1. Source Code The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The “System Libraries” of an executable work include anything, other than the work as a whole, that **(a)** is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and **(b)** serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. ### 2. Basic Permissions All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. ### 4. Conveying Verbatim Copies You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. ### 5. Conveying Modified Source Versions You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: * **a)** The work must carry prominent notices stating that you modified it, and giving a relevant date. * **b)** The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. * **c)** You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. * **d)** If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. ### 6. Conveying Non-Source Forms You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: * **a)** Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. * **b)** Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either **(1)** a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or **(2)** access to copy the Corresponding Source from a network server at no charge. * **c)** Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. * **d)** Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. * **e)** Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A “User Product” is either **(1)** a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or **(2)** anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. “Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. ### 7. Additional Terms “Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: * **a)** Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or * **b)** Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or * **c)** Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or * **d)** Limiting the use for publicity purposes of names of licensors or authors of the material; or * **e)** Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or * **f)** Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. ### 8. Termination You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated **(a)** provisionally, unless and until the copyright holder explicitly and finally terminates your license, and **(b)** permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. ### 9. Acceptance Not Required for Having Copies You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. ### 10. Automatic Licensing of Downstream Recipients Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. ### 11. Patents A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either **(1)** cause the Corresponding Source to be so available, or **(2)** arrange to deprive yourself of the benefit of the patent license for this particular work, or **(3)** arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license **(a)** in connection with copies of the covered work conveyed by you (or copies made from those copies), or **(b)** primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. ### 12. No Surrender of Others' Freedom If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. ### 13. Remote Network Interaction; Use with the GNU General Public License Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. ### 14. Revised Versions of this License The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. ### 15. Disclaimer of Warranty THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. ### 16. Limitation of Liability IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. ### 17. Interpretation of Sections 15 and 16 If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. _END OF TERMS AND CONDITIONS_ ## How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. Copyright (C) 2024 Gregor Vostrak & Constantin Graf This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a “Source” link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <>. ================================================ FILE: README.md ================================================ # solidtime - The modern Open-Source Time Tracker [![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md) [![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime) ![GitHub Actions Unit Tests Status](https://img.shields.io/github/actions/workflow/status/solidtime-io/solidtime/phpunit.yml?style=flat-square) ![PHPStan badge](https://img.shields.io/badge/PHPStan-Level_7-blue?style=flat-square&color=blue) ![Screenshot of the solidtime application with header: solidtime - The modern Open-Source Time Tracker](docs/solidtime-banner.png "solidtime Banner") solidtime is a modern open-source time tracking application for Freelancers and Agencies. ## Features - Time tracking: Track your time with a modern and easy-to-use interface - Projects: Create and manage projects and assign project members - Tasks: Create and manage tasks and assign tasks to projects - Clients: Create and manage clients and assign clients to projects - Billable rates: Set billable rates for projects, project members, organization members and organizations - Multiple organizations: Create and manage multiple organizations with one account - Roles and permissions: Create and manage organizations - Import: Import your time tracking data from other time tracking applications (Supported: Toggl, Clockify, Timeentry CSV) ## Self Hosting If you are looking into self-hosting solidtime, you can find the guides [here](https://docs.solidtime.io/self-hosting/intro) We also have an examples repository [here](https://github.com/solidtime-io/self-hosting-examples) If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/) ## Issues & Feature Requests If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug. If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository. ## 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. **If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.** Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request. We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides. ## Security Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. ## License This project is open-source and available under the GNU Affero General Public License v3.0 (AGPL v3). Please see the [license file](LICENSE.md) for more information. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)! ================================================ FILE: app/Actions/Fortify/CreateNewUser.php ================================================ $input * * @throws ValidationException */ public function create(array $input): User { if (! config('app.enable_registration')) { throw ValidationException::withMessages([ 'email' => [__('Registration is disabled.')], ]); } Validator::make($input, [ 'name' => [ 'required', 'string', 'max:255', ], 'email' => [ 'required', 'string', 'email:rfc,strict', 'max:255', UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->where('is_placeholder', '=', false); }), ], 'password' => $this->passwordRules(), 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', 'newsletter_consent' => [ 'boolean', ], ])->validate(); $timezone = null; if (array_key_exists('timezone', $input) && is_string($input['timezone'])) { if (app(TimezoneService::class)->isValid($input['timezone'])) { $timezone = $input['timezone']; } else { $timezone = app(TimezoneService::class)->mapLegacyTimezone($input['timezone']); if ($timezone === null) { Log::debug('Invalid timezone', ['timezone' => $input['timezone']]); } } } $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip()); $startOfWeek = Weekday::Monday; $numberFormat = null; $currencyFormat = null; $dateFormat = null; $intervalFormat = null; $timeFormat = null; $currency = null; if ($ipLookupResponse !== null) { $startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday; if ($timezone === null) { $timezone = $ipLookupResponse->timezone; } $currency = $ipLookupResponse->currency; } $user = null; DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat): void { $userService = app(UserService::class); $user = $userService->createUser( $input['name'], $input['email'], $input['password'], $timezone ?? 'UTC', $startOfWeek, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat ); }); $newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent']; if ($newsletterConsent) { NewsletterRegistered::dispatch($input['name'], $input['email'], $user->getKey()); } return $user; } } ================================================ FILE: app/Actions/Fortify/PasswordValidationRules.php ================================================ */ protected function passwordRules(): array { return ['required', 'string', Password::default(), 'confirmed']; } } ================================================ FILE: app/Actions/Fortify/ResetUserPassword.php ================================================ $input */ public function reset(User $user, array $input): void { Validator::make($input, [ 'password' => $this->passwordRules(), ])->validate(); $user->forceFill([ 'password' => Hash::make($input['password']), ])->save(); } } ================================================ FILE: app/Actions/Fortify/UpdateUserPassword.php ================================================ $input */ public function update(User $user, array $input): void { Validator::make($input, [ 'current_password' => ['required', 'string', 'current_password:web'], 'password' => $this->passwordRules(), ], [ 'current_password.current_password' => __('The provided password does not match your current password.'), ])->validateWithBag('updatePassword'); $user->forceFill([ 'password' => Hash::make($input['password']), ])->save(); } } ================================================ FILE: app/Actions/Fortify/UpdateUserProfileInformation.php ================================================ $input * * @throws ValidationException */ public function update(User $user, array $input): void { Validator::make($input, [ 'name' => [ 'required', 'string', 'max:255', ], 'email' => [ 'required', 'email', 'max:255', UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) { /** @var Builder $query */ return $query->where('is_placeholder', '=', false); }), ], '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'])) { $user->updateProfilePhoto($input['photo']); } if ($input['email'] !== $user->email) { $user->forceFill([ 'name' => $input['name'], 'email' => $input['email'], 'email_verified_at' => null, 'timezone' => $input['timezone'], 'week_start' => $input['week_start'], ])->save(); $user->sendEmailVerificationNotification(); } else { $user->forceFill([ 'name' => $input['name'], 'timezone' => $input['timezone'], 'week_start' => $input['week_start'], ])->save(); } } } ================================================ FILE: app/Actions/Jetstream/AddOrganizationMember.php ================================================ authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring $this->validate($organization, $email, $role); $newOrganizationMember = User::query() ->where('email', $email) ->where('is_placeholder', '=', false) ->firstOrFail(); app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role)); } /** * Validate the add member operation. */ protected function validate(Organization $organization, string $email, ?string $role): void { Validator::make([ 'email' => $email, 'role' => $role, ], $this->rules())->after( $this->ensureUserIsNotAlreadyOnTeam($organization, $email) )->validateWithBag('addTeamMember'); } /** * Get the validation rules for adding a team member. * * @return array> */ protected function rules(): array { return [ 'email' => [ 'required', 'email', ExistsEloquent::make(User::class, 'email', function (Builder $builder) { /** @var Builder $builder */ return $builder->where('is_placeholder', '=', false); })->withMessage(__('We were unable to find a registered user with this email address.')), ], 'role' => [ 'required', 'string', Rule::in([ Role::Admin->value, Role::Manager->value, Role::Employee->value, ]), ], ]; } /** * Ensure that the user is not already on the team. */ protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure { return function ($validator) use ($team, $email): void { $validator->errors()->addIf( $team->hasRealUserWithEmail($email), 'email', __('This user already belongs to the team.') ); }; } } ================================================ FILE: app/Actions/Jetstream/CreateOrganization.php ================================================ $input * * @throws AuthorizationException * @throws ValidationException */ public function create(User $user, array $input): Organization { Gate::forUser($user)->authorize('create', Jetstream::newTeamModel()); Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], ])->validateWithBag('createTeam'); $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip()); $currency = null; if ($ipLookupResponse !== null) { $currency = $ipLookupResponse->currency; } $organization = app(OrganizationService::class)->createOrganization( $input['name'], $user, false, $currency ); $user->switchTeam($organization); // Note: The refresh is necessary for currently unknown reasons. Do not remove it. $organization = $organization->refresh(); AfterCreateOrganization::dispatch($organization); return $organization; } } ================================================ FILE: app/Actions/Jetstream/DeleteOrganization.php ================================================ deleteOrganization($organization); } } ================================================ FILE: app/Actions/Jetstream/DeleteUser.php ================================================ deleteUser($user); } catch (ApiException $exception) { throw ValidationException::withMessages([ 'password' => $exception->getTranslatedMessage(), ]); } } } ================================================ FILE: app/Actions/Jetstream/InviteOrganizationMember.php ================================================ $input * * @throws AuthorizationException * @throws ValidationException */ public function update(User $user, Organization $organization, array $input): void { Gate::forUser($user)->authorize('update', $organization); Validator::make($input, [ 'name' => [ 'required', 'string', 'max:255', ], 'currency' => [ 'required', 'string', new CurrencyRule, ], ])->validateWithBag('updateTeamName'); $organization->forceFill([ 'name' => $input['name'], 'currency' => $input['currency'], ])->save(); } } ================================================ FILE: app/Actions/Jetstream/ValidateOrganizationDeletion.php ================================================ userHas($organization, $user, 'organizations:delete')) { throw new AuthorizationException; } } } ================================================ FILE: app/Console/Commands/Admin/OrganizationDeleteCommand.php ================================================ argument('organization'); if (! Str::isUuid($organizationId)) { $this->error('Organization ID must be a valid UUID.'); return self::FAILURE; } /** @var Organization|null $organization */ $organization = Organization::find($organizationId); if ($organization === null) { $this->error('Organization with ID '.$organizationId.' not found.'); return self::FAILURE; } $this->info('Deleting organization with ID '.$organization->getKey()); $deletionService->deleteOrganization($organization); $this->info('Organization with ID '.$organization->getKey().' has been deleted.'); return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/Admin/UserCreateCommand.php ================================================ argument('name'); $email = $this->argument('email'); $askForPassword = (bool) $this->option('ask-for-password'); $verifyEmail = (bool) $this->option('verify-email'); if (User::query()->where('email', $email)->where('is_placeholder', '=', false)->exists()) { $this->error('User with email "'.$email.'" already exists.'); return self::FAILURE; } if ($askForPassword) { $outputPassword = false; $password = $this->secret('Enter the password'); } else { $outputPassword = true; $password = bin2hex(random_bytes(16)); } $user = null; DB::transaction(function () use (&$user, $name, $email, $password, $verifyEmail): void { $user = app(UserService::class)->createUser( $name, $email, $password, 'UTC', Weekday::Monday, null, verifyEmail: $verifyEmail ); }); /** @var Organization|null $organization */ $organization = $user->ownedTeams->first(); if ($organization === null) { throw new LogicException('User does not have an organization'); } $this->info('Created user "'.$name.'" ("'.$email.'")'); $this->line('ID: '.$user->getKey()); $this->line('Name: '.$name); $this->line('Email: '.$email); if ($outputPassword) { $this->line('Password: '.$password); } $this->line('Timezone: '.$user->timezone); $this->line('Week start: '.$user->week_start->value); // Organization $this->line('Currency: '.$organization->currency); return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/Admin/UserVerifyCommand.php ================================================ argument('email'); $this->info('Start verifying user with email "'.$email.'"'); /** @var User|null $user */ $user = User::query()->where('email', $email) ->where('is_placeholder', '=', false) ->first(); if ($user === null) { $this->error('User with email "'.$email.'" not found.'); return self::FAILURE; } if ($user->hasVerifiedEmail()) { $this->info('User with email "'.$email.'" already verified.'); return self::FAILURE; } $user->markEmailAsVerified(); event(new Verified($user)); $this->info('User with email "'.$email.'" has been verified.'); return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommand.php ================================================ 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 $query */ $query->where('is_placeholder', '=', false); }) ->isApiToken(true) ->orderBy('created_at', 'asc') ->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void { /** @var Collection $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 $query */ $query->where('is_placeholder', '=', false); }) ->isApiToken(true) ->orderBy('created_at', 'asc') ->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void { /** @var Collection $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; } } ================================================ FILE: app/Console/Commands/Correction/CorrectionPlaceholderMembersCommand.php ================================================ comment('Sets all members who belong to a placeholder user to role placeholder...'); $dryRun = (bool) $this->option('dry-run'); if ($dryRun) { $this->comment('Running in dry-run mode. Nothing will be saved to the database.'); } $members = Member::query() ->where('role', '!=', Role::Placeholder->value) ->whereHas('user', function (Builder $builder): void { /** @var Builder $builder */ $builder->where('is_placeholder', '=', true); }) ->get(); foreach ($members as $member) { /** @var Member $member */ $member->role = Role::Placeholder->value; if (! $dryRun) { $member->save(); } $this->line('Set role of member (id='.$member->getKey().') to placeholder'); } return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/Report/ReportSetExpiredToPrivateCommand.php ================================================ comment('Makes public reports private if the public_until date has passed...'); $dryRun = (bool) $this->option('dry-run'); if ($dryRun) { $this->comment('Running in dry-run mode. Nothing will be saved to the database.'); } $resetReports = 0; Report::query() ->where('public_until', '<', Carbon::now()) ->orderBy('created_at', 'asc') ->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void { /** @var Collection $reports */ foreach ($reports as $report) { $publicUntil = $report->public_until; if ($publicUntil === null) { throw new LogicException('public_until should not be null'); } $this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '. $publicUntil->toIso8601ZuluString().' ('.$publicUntil->diffForHumans().')'); $resetReports++; if (! $dryRun) { $report->is_public = false; $report->share_secret = null; $report->save(); } } }); $this->comment('Finished setting '.$resetReports.' expired reports to private...'); return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/SelfHost/SelfHostCheckForUpdateCommand.php ================================================ checkForUpdate(); if ($latestVersion === null) { $this->error('Failed to check for update, check the logs for more information.'); return self::FAILURE; } // Note: Cache for 13 hours, because the command runs twice daily (every 12 hours). Cache::put('latest_version', $latestVersion, 60 * 60 * 12); return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/SelfHost/SelfHostDatabaseConsistency.php ================================================ select(['time_entries.id as id']) ->join('tasks', 'time_entries.task_id', '=', 'tasks.id') ->where('tasks.project_id', '!=', DB::raw('time_entries.project_id')) ->get(); $this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem); // Client id is the client id of the project $problems = DB::table('time_entries') ->select(['time_entries.id as id']) ->join('projects', 'time_entries.project_id', '=', 'projects.id') ->where(DB::raw('coalesce(projects.client_id::varchar, \'\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \'\')')) ->get(); $this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem); // Client id can only be not null if the project id is not null $problems = DB::table('time_entries') ->select(['time_entries.id as id']) ->whereNotNull('client_id') ->whereNull('project_id') ->get(); $this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem); // Every user needs to be a member of at least one organization $problems = DB::table('users') ->select(['users.id as id']) ->leftJoin('members', 'users.id', '=', 'members.user_id') ->whereNull('members.id') ->get(); $this->logProblems($problems, 'Users are not member of any organization', $hadAProblem); // Every organization needs at least an owner $problems = DB::table('organizations') ->select(['organizations.id as id']) ->leftJoin('members', function (JoinClause $join): void { $join->on('organizations.id', '=', 'members.organization_id') ->where('members.role', '=', 'owner'); }) ->whereNull('members.id') ->get(); $this->logProblems($problems, 'Organizations without an owner', $hadAProblem); // Every member can only have one running time entry $problems = DB::table('time_entries') ->select(['user_id as id']) ->whereNull('end') ->groupBy('user_id') ->havingRaw('count(*) > 1') ->get(['user_id', DB::raw('count(*) as count')]); $this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem); // Users have a current organization that they are not a member of $problems = DB::table('users') ->select(['users.id as id']) ->whereNotNull('current_team_id') ->whereNotIn('current_team_id', function (Builder $query): void { $query->select('organization_id') ->from('members') ->whereColumn('members.user_id', 'users.id'); })->get(); $this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem); return $hadAProblem ? self::FAILURE : self::SUCCESS; } /** * @param Collection $problems */ private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void { $message = 'Consistency problem: '.$message; if ($problems->isNotEmpty()) { $ids = $problems->pluck('id'); $hadAProblem = true; Log::error($message, [ 'ids' => $ids, ]); $error = $message; foreach ($ids as $id) { $error .= "\n - ".$id; } $this->error($error); } } } ================================================ FILE: app/Console/Commands/SelfHost/SelfHostGenerateKeysCommand.php ================================================ option('format'); $key = RSA::createKey((int) $this->option('length')); $multiLine = (bool) $this->option('multi-line'); $publicKey = (string) $key->getPublicKey(); $privateKey = (string) $key; $appKey = 'base64:'.base64_encode(Encrypter::generateKey(config('app.cipher'))); if ($format === 'env') { $this->line('APP_KEY="'.$appKey.'"'); if ($multiLine) { $this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", "\n", $privateKey).'"'); $this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", "\n", $publicKey).'"'); } else { $this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", '\n', $privateKey).'"'); $this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", '\n', $publicKey).'"'); } } elseif ($format === 'yaml') { $this->line('APP_KEY: "'.$appKey.'"'); $this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\r\n", "\n ", $privateKey)); $this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\r\n", "\n ", $publicKey)); } else { $this->error('Invalid format'); return self::FAILURE; } return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/SelfHost/SelfHostTelemetryCommand.php ================================================ telemetry(); if (! $success) { $this->error('Failed to send telemetry data, check the logs for more information.'); return self::FAILURE; } return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/Test/TestEmailCommand.php ================================================ argument('email'); Mail::raw('Hello World!', function (Message $message) use ($email): void { $message->to($email) ->subject('Test Email') ->html('

Hello World!

'); }); return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/Test/TestJobCommand.php ================================================ option('fail'); TestJob::dispatch($user, 'Test job message.', $fail); return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/Test/TestOutputCommand.php ================================================ info('Test command output'); $this->error('Test command output error'); return self::SUCCESS; } } ================================================ FILE: app/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommand.php ================================================ comment('Sending still running time entry emails...'); $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.'); } $sentMails = 0; TimeEntry::query() ->whereNull('end') ->where('start', '<', now()->subHours(8)) ->whereNull('still_active_email_sent_at') ->with([ 'user', ]) ->whereHas('user', function (Builder $query): void { /** @var Builder $query */ $query->where('is_placeholder', '=', false); }) ->orderBy('created_at', 'asc') ->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void { /** @var Collection $timeEntries */ foreach ($timeEntries as $timeEntry) { $user = $timeEntry->user; $this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') for time entry '.$timeEntry->getKey()); $sentMails++; if (! $dryRun) { Mail::to($user->email) ->queue(new TimeEntryStillRunningMail($timeEntry, $user)); $timeEntry->still_active_email_sent_at = Carbon::now(); $timeEntry->save(); } } }); $this->comment('Finished sending '.$sentMails.' still running time entry emails...'); return self::SUCCESS; } } ================================================ FILE: app/Console/Kernel.php ================================================ command('time-entry:send-still-running-mails') ->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails')) ->everyTenMinutes(); $schedule->command('auth:send-mails-expiring-api-tokens') ->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens')) ->everyTenMinutes(); if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) { // Convert string to a stable integer for seeding /** @var int $seed Take the first 8 hex chars → 32-bit int */ $seed = hexdec(substr(hash('md5', config('app.key')), 0, 8)); $seed = abs($seed); // Ensure it's positive mt_srand($seed); $firstHour = mt_rand(0, 23); $secondHour = ($firstHour + 12) % 24; $minuteOffset = mt_rand(0, 59); mt_srand(null); // Reset the random number generator if (config('scheduling.tasks.self_hosting_check_for_update')) { $schedule->command('self-host:check-for-update') ->twiceDailyAt($firstHour, $secondHour, $minuteOffset); } if (config('scheduling.tasks.self_hosting_telemetry')) { $schedule->command('self-host:telemetry') ->twiceDailyAt($firstHour, $secondHour, $minuteOffset); } } $schedule->command('self-host:database-consistency') ->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency')) ->everySixHours(); } /** * Register the commands for the application. */ protected function commands(): void { $this->load(__DIR__.'/Commands'); } } ================================================ FILE: app/Enums/CurrencyFormat.php ================================================ */ public static function toSelectArray(): array { $selectArray = []; foreach (self::values() as $value) { $selectArray[(string) $value] = (string) __('enum.currency_format.'.$value); } return $selectArray; } } ================================================ FILE: app/Enums/DateFormat.php ================================================ value) { self::PointSeparatedDMYYYY->value => 'j.n.Y', self::SlashSeparatedMMDDYYYY->value => 'm/d/Y', self::SlashSeparatedDDMMYYYY->value => 'd/m/Y', self::HyphenSeparatedDDMMYYY->value => 'd-m-Y', self::HyphenSeparatedMMDDDYYYY->value => 'm-d-Y', self::HyphenSeparatedYYYYMMDD->value => 'Y-m-d', }; } /** * @return array */ public static function toSelectArray(): array { $selectArray = []; foreach (self::values() as $value) { $selectArray[(string) $value] = (string) __('enum.date_format.'.$value); } return $selectArray; } } ================================================ FILE: app/Enums/ExportFormat.php ================================================ 'csv', self::PDF => 'pdf', self::XLSX => 'xlsx', self::ODS => 'ods', }; } public function getExportPackageType(): string { return match ($this) { self::CSV => Excel::CSV, self::PDF => Excel::MPDF, self::XLSX => Excel::XLSX, self::ODS => Excel::ODS, }; } } ================================================ FILE: app/Enums/IntervalFormat.php ================================================ */ public static function toSelectArray(): array { $selectArray = []; foreach (self::values() as $value) { $selectArray[(string) $value] = (string) __('enum.interval_format.'.$value); } return $selectArray; } } ================================================ FILE: app/Enums/NumberFormat.php ================================================ */ public static function toSelectArray(): array { $selectArray = []; foreach (self::values() as $value) { $selectArray[(string) $value] = (string) __('enum.number_format.'.$value); } return $selectArray; } } ================================================ FILE: app/Enums/Role.php ================================================ TimeEntryAggregationType::Day, TimeEntryAggregationTypeInterval::Week => TimeEntryAggregationType::Week, TimeEntryAggregationTypeInterval::Month => TimeEntryAggregationType::Month, TimeEntryAggregationTypeInterval::Year => TimeEntryAggregationType::Year, }; } public function toInterval(): ?TimeEntryAggregationTypeInterval { return match ($this) { TimeEntryAggregationType::Day => TimeEntryAggregationTypeInterval::Day, TimeEntryAggregationType::Week => TimeEntryAggregationTypeInterval::Week, TimeEntryAggregationType::Month => TimeEntryAggregationTypeInterval::Month, TimeEntryAggregationType::Year => TimeEntryAggregationTypeInterval::Year, default => null }; } } ================================================ FILE: app/Enums/TimeEntryAggregationTypeInterval.php ================================================ */ public static function toSelectArray(): array { $selectArray = []; foreach (self::values() as $value) { $selectArray[(string) $value] = (string) __('enum.time_format.'.$value); } return $selectArray; } } ================================================ FILE: app/Enums/Weekday.php ================================================ Weekday::Sunday, Weekday::Tuesday => Weekday::Monday, Weekday::Wednesday => Weekday::Tuesday, Weekday::Thursday => Weekday::Wednesday, Weekday::Friday => Weekday::Thursday, Weekday::Saturday => Weekday::Friday, Weekday::Sunday => Weekday::Saturday, }; } public function carbonWeekDay(): int { return match ($this) { Weekday::Monday => 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), ]; } } ================================================ FILE: app/Events/AfterCreateOrganization.php ================================================ organization = $organization; } } ================================================ FILE: app/Events/BeforeOrganizationDeletion.php ================================================ organization = $organization; } } ================================================ FILE: app/Events/DatabaseSeederAfterSeed.php ================================================ member = $member; $this->organization = $organization; } } ================================================ FILE: app/Events/MemberRemoved.php ================================================ member = $member; $this->organization = $organization; } } ================================================ FILE: app/Events/NewsletterRegistered.php ================================================ name = $name; $this->email = $email; $this->id = $id; } } ================================================ FILE: app/Exceptions/Api/ApiException.php ================================================ json([ 'error' => true, 'key' => $this->getKey(), 'message' => $this->getTranslatedMessage(), ], 400); } /** * Get the key for the exception. */ public function getKey(): string { $key = static::KEY; if ($key === ApiException::KEY) { throw new LogicException('API exceptions need the KEY constant defined.'); } return $key; } /** * Get the translated message for the exception. */ public function getTranslatedMessage(): string { return __('exceptions.api.'.$this->getKey()); } /** * Report the exception. * * @return bool true means the exception handler will not report it again */ public function report(): bool { // TODO: temporary activated return false; } } ================================================ FILE: app/Exceptions/Api/CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers.php ================================================ modelToDelete = $modelToDelete; $this->modelInUse = $modelInUse; } public const string KEY = 'entity_still_in_use'; /** * Get the translated message for the exception. */ #[\Override] public function getTranslatedMessage(): string { return __('exceptions.api.'.$this->getKey(), [ 'modelToDelete' => __('validation.entities.'.$this->modelToDelete), 'modelInUse' => __('validation.entities.'.$this->modelInUse), ]); } } ================================================ FILE: app/Exceptions/Api/FeatureIsNotAvailableInFreePlanApiException.php ================================================ */ protected $dontFlash = [ 'current_password', 'password', 'password_confirmation', ]; /** * Register the exception handling callbacks for the application. */ public function register(): void { $this->reportable(function (Throwable $e): void { // }); } public function render($request, Throwable $e): Response|RedirectResponse { $response = parent::render($request, $e); if ($response->getStatusCode() === 419) { return back()->with([ 'message' => 'The page expired, please try again.', ]); } return $response; } } ================================================ FILE: app/Exceptions/MovedToApiException.php ================================================ preloadedResolverData['ip_address'] ?? Request::ip(); if ($ip !== null) { $ip = self::anonymizeIpAddress($ip); } return $ip; } } ================================================ FILE: app/Extensions/Fortify/CustomLoginResponse.php ================================================ pull('url.intended', route('dashboard', [], false)); return $request->wantsJson() ? response()->json(['two_factor' => false]) : Inertia::location($redirectPath); } } ================================================ FILE: app/Extensions/Fortify/CustomTwoFactorLoginResponse.php ================================================ pull('url.intended', route('dashboard', [], false)); return $request->wantsJson() ? new JsonResponse('', 204) : Inertia::location($redirectPath); } } ================================================ FILE: app/Extensions/Scramble/ApiExceptionTypeToSchema.php ================================================ isInstanceOf(ApiException::class); } public function toResponse(Type $type): Response { $validationResponseBodyType = (new OpenApiTypes\ObjectType) ->addProperty( 'error', (new OpenApiTypes\BooleanType) ->setDescription('Whether the response is an error.') ) ->addProperty( 'key', (new OpenApiTypes\StringType) ->setDescription('Error key.') ) ->addProperty( 'message', (new OpenApiTypes\StringType) ->setDescription('Error message.') ) ->setRequired(['error', 'key', 'message']); return Response::make(400) ->description('API exception') ->setContent( 'application/json', Schema::fromType($validationResponseBodyType) ); } public function reference(ObjectType $type): Reference { return new Reference('responses', Str::start($type->name, '\\'), $this->components); } } ================================================ FILE: app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php ================================================ isInstanceOf(PaginatedResourceCollection::class); } public function toSchema(Type $type): ?OpenApiObjectType { /** @var Type|null $collectingClassType */ $collectingClassType = $type->templateTypes[0] ?? null; if (! $collectingClassType instanceof ObjectType) { return null; } if (! $collectingClassType->isInstanceOf(JsonResource::class) && ! $collectingClassType->isInstanceOf(Model::class)) { return null; } $collectingType = $this->openApiTransformer->transform($collectingClassType); $newType = new OpenApiObjectType; $newType->addProperty('data', (new ArrayType)->setItems($collectingType)); if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) { $newType->addProperty( 'meta', (new OpenApiObjectType) ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.')) ->setRequired(['total']) ); $newType->setRequired(['data', 'meta']); } else { $newType->addProperty( 'links', (new OpenApiObjectType) ->addProperty('first', (new StringType)->nullable(true)) ->addProperty('last', (new StringType)->nullable(true)) ->addProperty('prev', (new StringType)->nullable(true)) ->addProperty('next', (new StringType)->nullable(true)) ->setRequired(['first', 'last', 'prev', 'next']) ); $newType->addProperty( 'meta', (new OpenApiObjectType) ->addProperty('current_page', new IntegerType) ->addProperty('from', (new IntegerType)->nullable(true)) ->addProperty('last_page', new IntegerType) ->addProperty('links', (new ArrayType)->setItems( (new OpenApiObjectType) ->addProperty('url', (new StringType)->nullable(true)) ->addProperty('label', new StringType) ->addProperty('active', new BooleanType) ->setRequired(['url', 'label', 'active']) )->setDescription('Generated paginator links.')) ->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.')) ->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.')) ->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.')) ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.')) ->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total']) ); $newType->setRequired(['data', 'links', 'meta']); } return $newType; } /** * @param Generic $type */ public function toResponse(Type $type): ?Response { /** @var ObjectType|null $collectingClassType */ $collectingClassType = $type->templateTypes[0] ?? null; if (! $collectingClassType instanceof ObjectType) { return null; } $type = $this->toSchema($type); return Response::make(200) ->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`') ->setContent('application/json', Schema::fromType($type)); } } ================================================ FILE: app/Filament/Resources/AuditResource/Pages/CreateAudit.php ================================================ schema([ Forms\Components\TextInput::make('user_type') ->maxLength(255), Forms\Components\TextInput::make('user_id'), Forms\Components\TextInput::make('event') ->required() ->maxLength(255), Forms\Components\TextInput::make('auditable_type') ->required() ->maxLength(255), Forms\Components\TextInput::make('auditable_id') ->required(), PrettyJsonField::make('old_values'), PrettyJsonField::make('new_values'), Forms\Components\Textarea::make('url'), Forms\Components\TextInput::make('ip_address'), Forms\Components\TextInput::make('user_agent') ->maxLength(1023), Forms\Components\TextInput::make('tags') ->maxLength(255), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('user.name'), Tables\Columns\TextColumn::make('event'), Tables\Columns\TextColumn::make('auditable_type'), Tables\Columns\TextColumn::make('auditable_id'), IconColumn::make('was_command') ->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan ')) ->boolean(), Tables\Columns\TextColumn::make('created_at') ->sortable() ->dateTime(), Tables\Columns\TextColumn::make('updated_at') ->sortable() ->dateTime(), ]) ->filters([ // ]) ->actions([ Tables\Actions\ViewAction::make(), ]) ->bulkActions([ ]) ->defaultSort('created_at', 'desc'); } public static function getRelations(): array { return [ ]; } public static function getPages(): array { return [ 'index' => Pages\ListAudits::route('/'), 'create' => Pages\CreateAudit::route('/create'), 'view' => Pages\ViewAudit::route('/{record}'), ]; } } ================================================ FILE: app/Filament/Resources/ClientResource/Pages/CreateClient.php ================================================ icon('heroicon-m-trash'), ]; } } ================================================ FILE: app/Filament/Resources/ClientResource/Pages/ListClients.php ================================================ icon('heroicon-s-plus'), ]; } } ================================================ FILE: app/Filament/Resources/ClientResource.php ================================================ schema([ TextInput::make('name') ->label('Name') ->required(), Select::make('organization_id') ->relationship(name: 'organization', titleAttribute: 'name') ->label('Organization') ->searchable(['name']) ->required(), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->label('Name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('organization.name') ->sortable() ->label('Organization'), Tables\Columns\TextColumn::make('created_at') ->label('Created at') ->sortable(), Tables\Columns\TextColumn::make('updated_at') ->label('Updated at') ->sortable(), ]) ->defaultSort('created_at', 'desc') ->filters([ SelectFilter::make('organization') ->label('Organization') ->relationship('organization', 'name') ->searchable(), SelectFilter::make('organization_id') ->label('Organization ID') ->relationship('organization', 'id') ->searchable(), ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } public static function getRelations(): array { return [ // ]; } public static function getPages(): array { return [ 'index' => Pages\ListClients::route('/'), 'create' => Pages\CreateClient::route('/create'), 'edit' => Pages\EditClient::route('/{record}/edit'), ]; } } ================================================ FILE: app/Filament/Resources/FailedJobResource/Pages/ListFailedJobs.php ================================================ icon('heroicon-o-arrow-path') ->label('Retry all') ->requiresConfirmation() ->action(function (): void { Artisan::call('queue:retry all'); Notification::make() ->title('All failed jobs have been pushed back onto the queue.') ->success() ->send(); }), Action::make('delete_all') ->icon('heroicon-o-trash') ->label('Delete all') ->requiresConfirmation() ->color('danger') ->action(function (): void { FailedJob::truncate(); Notification::make() ->title('All failed jobs have been removed.') ->success() ->send(); }), ]; } } ================================================ FILE: app/Filament/Resources/FailedJobResource/Pages/ViewFailedJobs.php ================================================ count(); } public static function form(Form $form): Form { return $form ->schema([ TextInput::make('uuid')->disabled()->columnSpan(4), TextInput::make('failed_at')->disabled(), TextInput::make('id')->disabled(), TextInput::make('connection')->disabled(), TextInput::make('queue')->disabled(), // make text a little bit smaller because often a complete Stack Trace is shown: TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']), PrettyJsonField::make('payload')->disabled()->columnSpan(4), ])->columns(4); } public static function table(Table $table): Table { return $table ->defaultSort('id', 'desc') ->columns([ TextColumn::make('id')->sortable()->searchable()->toggleable(), TextColumn::make('failed_at')->sortable()->searchable(false)->toggleable(), TextColumn::make('exception') ->sortable() ->searchable() ->toggleable() ->wrap() ->limit(200) ->tooltip(fn (FailedJob $record) => "{$record->failed_at} UUID: {$record->uuid}; Connection: {$record->connection}; Queue: {$record->queue};"), TextColumn::make('uuid')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true), TextColumn::make('connection')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true), TextColumn::make('queue')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true), ]) ->filters([]) ->bulkActions([ BulkAction::make('retry') ->icon('heroicon-o-arrow-path') ->label('Retry selected') ->requiresConfirmation() ->action(function (Collection $records): void { /** @var FailedJob $record */ foreach ($records as $record) { Artisan::call("queue:retry {$record->uuid}"); } Notification::make() ->title("{$records->count()} jobs have been pushed back onto the queue.") ->success() ->send(); }), DeleteBulkAction::make(), ]) ->actions([ DeleteAction::make(), ViewAction::make(), Action::make('retry') ->icon('heroicon-o-arrow-path') ->label('Retry') ->requiresConfirmation() ->action(function (FailedJob $record): void { Artisan::call("queue:retry {$record->uuid}"); Notification::make() ->title("The job with uuid '{$record->uuid}' has been pushed back onto the queue.") ->success() ->send(); }), ]); } public static function getPages(): array { return [ 'index' => ListFailedJobs::route('/'), 'view' => ViewFailedJobs::route('/{record}'), ]; } } ================================================ FILE: app/Filament/Resources/OrganizationInvitationResource/Pages/EditOrganizationInvitation.php ================================================ icon('heroicon-m-trash'), ]; } } ================================================ FILE: app/Filament/Resources/OrganizationInvitationResource/Pages/ListOrganizationInvitations.php ================================================ icon('heroicon-s-pencil'), ]; } } ================================================ FILE: app/Filament/Resources/OrganizationInvitationResource.php ================================================ columns(1) ->schema([ Forms\Components\TextInput::make('email') ->label('Email') ->disabledOn(['edit']) ->required(), Select::make('role') ->options(Role::class), Forms\Components\Select::make('organization_id') ->label('Organization') ->relationship(name: 'organization', titleAttribute: 'name') ->searchable(['name']) ->disabledOn(['edit']) ->required(), Forms\Components\DateTimePicker::make('created_at') ->label('Created At') ->hiddenOn(['create']) ->disabled(), Forms\Components\DateTimePicker::make('updated_at') ->label('Updated At') ->hiddenOn(['create']) ->disabled(), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('organization.name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('email') ->sortable(), Tables\Columns\TextColumn::make('role'), Tables\Columns\TextColumn::make('created_at') ->label('Created At') ->dateTime() ->sortable(), Tables\Columns\TextColumn::make('updated_at') ->label('Updated At') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->defaultSort('created_at', 'desc') ->filters([ // ]) ->actions([ Tables\Actions\EditAction::make(), Tables\Actions\DeleteAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\BulkAction::make('resend') ->label('Resend') ->action(function (Collection $records): void { foreach ($records as $organizationInvite) { app(OrganizationInvitationService::class)->resend($organizationInvite); } }), ]), ]); } public static function getRelations(): array { return [ ]; } public static function getPages(): array { return [ 'index' => Pages\ListOrganizationInvitations::route('/'), 'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'), 'view' => Pages\ViewOrganizationInvitation::route('/{record}'), ]; } } ================================================ FILE: app/Filament/Resources/OrganizationResource/Actions/DeleteOrganization.php ================================================ icon('heroicon-m-trash'); $this->action(function (): void { $result = $this->process(function (Organization $record): bool { try { $deletionService = app(DeletionService::class); $deletionService->deleteOrganization($record); return true; } catch (ApiException $exception) { $this->failureNotificationTitle($exception->getTranslatedMessage()); report($exception); } catch (Throwable $exception) { $this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel')); report($exception); } return false; }); if (! $result) { $this->failure(); return; } $this->success(); }); } } ================================================ FILE: app/Filament/Resources/OrganizationResource/Pages/CreateOrganization.php ================================================ record; $user = $organization->owner; $organization->users()->attach( $user, [ 'role' => Role::Owner->value, ] ); } } ================================================ FILE: app/Filament/Resources/OrganizationResource/Pages/EditOrganization.php ================================================ icon('heroicon-s-plus'), ]; } } ================================================ FILE: app/Filament/Resources/OrganizationResource/Pages/ViewOrganization.php ================================================ icon('heroicon-s-pencil'), ]; } } ================================================ FILE: app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php ================================================ schema([ TextInput::make('email') ->label('Email') ->disabledOn(['edit']) ->required(), Select::make('role') ->options(Role::class) ->label('Role') ->rules([ 'required', 'string', Rule::enum(Role::class) ->except([Role::Owner, Role::Placeholder]), ]) ->required(), ]); } public function table(Table $table): Table { return $table ->recordTitleAttribute('email') ->modelLabel('Invitation') ->pluralModelLabel('Invitations') ->columns([ Tables\Columns\TextColumn::make('email'), Tables\Columns\TextColumn::make('role'), ]) ->headerActions([ Tables\Actions\CreateAction::make() ->icon('heroicon-s-plus') ->using(function (array $data, string $model): Model { /** @var Organization $ownerRecord */ $ownerRecord = $this->getOwnerRecord(); return app(InvitationService::class) ->inviteUser($ownerRecord, $data['email'], Role::from($data['role'])); }), ]) ->actions([ Action::make('view') ->icon('heroicon-o-eye') ->color('gray') ->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [ 'record' => $record->getKey(), ])), Tables\Actions\EditAction::make(), Tables\Actions\DeleteAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DetachBulkAction::make(), ]), ]); } } ================================================ FILE: app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php ================================================ schema([ Select::make('role') ->options(Role::class), TextInput::make('billable_rate') ->label('Billable rate (in Cents)') ->nullable() ->numeric(), ]); } public function table(Table $table): Table { /** @var Organization $organization */ $organization = $this->getOwnerRecord(); return $table ->recordTitleAttribute('name') ->columns([ Tables\Columns\TextColumn::make('name'), Tables\Columns\TextColumn::make('role'), TextColumn::make('billable_rate') ->money($organization->currency, divideBy: 100), ]) ->headerActions([ Tables\Actions\AttachAction::make() ->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})") ->form(fn (AttachAction $action): array => [ $action->getRecordSelect(), Select::make('role') ->required() ->options(Role::class) ->rule([ 'required', 'string', Rule::enum(Role::class) ->except([Role::Owner, Role::Placeholder]), ]), ]) ->label('Add user') ->modalHeading('Add user') ->icon('heroicon-s-plus') ->using(function (User $record, array $data): void { /** @var Organization $organization */ $organization = $this->getOwnerRecord(); app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true); }), ]) ->actions([ Action::make('view') ->icon('heroicon-o-eye') ->color('gray') ->url(fn (User $record): string => UserResource::getUrl('view', [ 'record' => $record->getKey(), ])), Tables\Actions\EditAction::make() ->using(function (User $record, array $data): User { /** @var Organization $organization */ $organization = $this->getOwnerRecord(); /** @var Member $member */ $member = $record->getRelation('membership'); if ($data['billable_rate'] !== $member->billable_rate) { $member->billable_rate = $data['billable_rate']; app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member); } if ($data['role'] !== $member->role) { try { app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true); } catch (ApiException $exception) { Notification::make() ->danger() ->title('Update failed') ->body($exception->getTranslatedMessage()) ->persistent() ->send(); } } $member->save(); return $record; }), Tables\Actions\DetachAction::make() ->using(function (User $record): void { /** @var Organization $organization */ $organization = $this->getOwnerRecord(); $member = Member::query() ->whereBelongsTo($record, 'user') ->whereBelongsTo($organization, 'organization') ->firstOrFail(); try { app(MemberService::class)->removeMember($member, $organization); } catch (ApiException $exception) { Notification::make() ->danger() ->title('Delete failed') ->body($exception->getTranslatedMessage()) ->persistent() ->send(); } }), ]) ->bulkActions([ ]); } } ================================================ FILE: app/Filament/Resources/OrganizationResource.php ================================================ columns(1) ->schema([ Forms\Components\TextInput::make('name') ->label('Name') ->required() ->maxLength(255), Forms\Components\Toggle::make('personal_team') ->label('Is personal?') ->hiddenOn(['create']) ->required(), Forms\Components\Select::make('user_id') ->label('Owner') ->relationship(name: 'owner', titleAttribute: 'email') ->searchable(['name', 'email']) ->disabledOn(['edit']) ->required(), Select::make('date_format') ->options(DateFormat::toSelectArray()) ->required(), Select::make('currency_format') ->options(CurrencyFormat::toSelectArray()) ->required(), Select::make('interval_format') ->options(IntervalFormat::toSelectArray()) ->required(), Select::make('number_format') ->options(NumberFormat::toSelectArray()) ->required(), Select::make('time_format') ->options(TimeFormat::toSelectArray()) ->required(), Forms\Components\Select::make('currency') ->label('Currency') ->options(function (): array { $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); $select = []; foreach ($currencies as $currency) { $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')'; } return $select; }) ->required() ->searchable(), Forms\Components\TextInput::make('billable_rate') ->label('Billable rate (in Cents)') ->nullable() ->rules([ 'nullable', 'integer', 'gt:0', 'max:2147483647', ]) ->numeric(), Forms\Components\DateTimePicker::make('created_at') ->label('Created At') ->hiddenOn(['create']) ->disabled(), Forms\Components\DateTimePicker::make('updated_at') ->label('Updated At') ->hiddenOn(['create']) ->disabled(), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), Tables\Columns\IconColumn::make('personal_team') ->boolean() ->label('Is personal?') ->sortable(), Tables\Columns\TextColumn::make('owner.email') ->sortable(), Tables\Columns\TextColumn::make('currency'), TextColumn::make('billable_rate') ->money(fn (Organization $resource) => $resource->currency, divideBy: 100), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable(), Tables\Columns\TextColumn::make('updated_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->defaultSort('created_at', 'desc') ->filters([ // ]) ->actions([ Tables\Actions\EditAction::make(), Tables\Actions\DeleteAction::make() ->using(function (Organization $record): void { app(DeletionService::class)->deleteOrganization($record); }), Action::make('Export') ->icon('heroicon-o-arrow-down-tray') ->action(function (Organization $record) { try { $file = app(ExportService::class)->export($record); Notification::make() ->title('Export successful') ->success() ->persistent() ->send(); return response()->streamDownload(function () use ($file): void { echo Storage::disk(config('filesystems.private'))->get($file); }, 'export.zip'); } catch (\Exception $exception) { report($exception); Notification::make() ->title('Export failed') ->danger() ->body('Message: '.$exception->getMessage()) ->persistent() ->send(); } }), Action::make('Import') ->icon('heroicon-o-inbox-arrow-down') ->action(function (Organization $record, array $data): void { try { $file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']); if ($file === null) { throw new \Exception('File not found'); } /** @var string $timezone */ $timezone = $data['timezone']; /** @var ReportDto $report */ $report = app(ImportService::class)->import( $record, $data['type'], $file, $timezone ); Notification::make() ->title('Import successful') ->success() ->body( 'Imported time entries: '.$report->timeEntriesCreated.'
'. 'Imported clients: '.$report->clientsCreated.'
'. 'Imported projects: '.$report->projectsCreated.'
'. 'Imported tasks: '.$report->tasksCreated.'
'. 'Imported tags: '.$report->tagsCreated.'
'. 'Imported users: '.$report->usersCreated ) ->persistent() ->send(); } catch (ImportException $exception) { report($exception); Notification::make() ->title('Import failed, changes rolled back') ->danger() ->body('Message: '.$exception->getMessage()) ->persistent() ->send(); } }) ->tooltip(fn (Organization $record): string => 'Import into '.$record->name) ->form([ Forms\Components\FileUpload::make('file') ->label('File') ->required(), Select::make('type') ->required() ->options(function (): array { $select = []; foreach (app(ImporterProvider::class)->getImporterKeys() as $key) { $select[$key] = $key; } return $select; }), Forms\Components\Select::make('timezone') ->label('Timezone') ->options(fn (): array => app(TimezoneService::class)->getSelectOptions()) ->searchable() ->required(), ]), ]) ->bulkActions([ ]); } public static function getRelations(): array { return [ UsersRelationManager::class, InvitationsRelationManager::class, ]; } public static function getPages(): array { return [ 'index' => Pages\ListOrganizations::route('/'), 'create' => Pages\CreateOrganization::route('/create'), 'edit' => Pages\EditOrganization::route('/{record}/edit'), 'view' => Pages\ViewOrganization::route('/{record}'), ]; } } ================================================ FILE: app/Filament/Resources/ProjectMemberResource/Pages/CreateProjectMember.php ================================================ icon('heroicon-m-trash'), ]; } } ================================================ FILE: app/Filament/Resources/ProjectMemberResource/Pages/ListProjectMembers.php ================================================ icon('heroicon-s-plus'), ]; } } ================================================ FILE: app/Filament/Resources/ProjectMemberResource/Pages/ViewProjectMembers.php ================================================ icon('heroicon-s-pencil'), ]; } } ================================================ FILE: app/Filament/Resources/ProjectMemberResource.php ================================================ schema([ Forms\Components\TextInput::make('billable_rate') ->label('Billable rate (in Cents)') ->nullable() ->rules([ 'nullable', 'integer', 'gt:0', 'max:2147483647', ]) ->numeric(), Forms\Components\Select::make('user_id') ->relationship('user', 'name') ->required(), Forms\Components\Select::make('member_id') ->relationship('member', 'id') ->required(), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('id') ->label('ID'), Tables\Columns\TextColumn::make('billable_rate') ->numeric() ->sortable(), Tables\Columns\TextColumn::make('project.name'), Tables\Columns\TextColumn::make('user.name'), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable(), Tables\Columns\TextColumn::make('updated_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ // ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } public static function getRelations(): array { return [ // ]; } public static function getPages(): array { return [ 'index' => Pages\ListProjectMembers::route('/'), 'create' => Pages\CreateProjectMember::route('/create'), 'edit' => Pages\EditProjectMember::route('/{record}/edit'), 'view' => Pages\ViewProjectMembers::route('/{record}'), ]; } } ================================================ FILE: app/Filament/Resources/ProjectResource/Pages/CreateProject.php ================================================ icon('heroicon-m-trash'), ]; } } ================================================ FILE: app/Filament/Resources/ProjectResource/Pages/ListProjects.php ================================================ icon('heroicon-s-plus'), ]; } } ================================================ FILE: app/Filament/Resources/ProjectResource/RelationManagers/ProjectMembersRelationManager.php ================================================ schema([ ]); } public function table(Table $table): Table { return $table ->recordTitleAttribute('name') ->columns([ Tables\Columns\TextColumn::make('user.name'), Tables\Columns\TextColumn::make('billable_rate') ->numeric() ->sortable(), ]) ->filters([ // ]) ->headerActions([ ]) ->actions([ Action::make('view') ->icon('heroicon-o-eye') ->color('gray') ->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('view', [ 'record' => $record->getKey(), ])), Action::make('edit') ->icon('heroicon-o-pencil') ->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('edit', [ 'record' => $record->getKey(), ])) ->openUrlInNewTab(), ]) ->bulkActions([ ]); } } ================================================ FILE: app/Filament/Resources/ProjectResource.php ================================================ schema([ Forms\Components\TextInput::make('name') ->label('Name') ->required() ->maxLength(255), ColorPicker::make('color') ->label('Color') ->required(), Forms\Components\TextInput::make('billable_rate') ->label('Billable rate (in Cents)') ->nullable() ->rules([ 'nullable', 'integer', 'gt:0', 'max:2147483647', ]) ->numeric(), Forms\Components\Select::make('organization_id') ->relationship(name: 'organization', titleAttribute: 'name') ->searchable(['name']) ->required(), ]); } public static function table(Table $table): Table { return $table ->columns([ ColorColumn::make('color'), TextColumn::make('name') ->searchable() ->sortable(), TextColumn::make('organization.name') ->sortable(), TextColumn::make('created_at') ->sortable(), TextColumn::make('updated_at') ->sortable(), ]) ->filters([ SelectFilter::make('organization') ->label('Organization') ->relationship('organization', 'name') ->searchable(), SelectFilter::make('organization_id') ->label('Organization ID') ->relationship('organization', 'id') ->searchable(), ]) ->defaultSort('created_at', 'desc') ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } public static function getRelations(): array { return [ ProjectMembersRelationManager::make(), ]; } public static function getPages(): array { return [ 'index' => Pages\ListProjects::route('/'), 'create' => Pages\CreateProject::route('/create'), 'edit' => Pages\EditProject::route('/{record}/edit'), ]; } } ================================================ FILE: app/Filament/Resources/ReportResource/Pages/EditReport.php ================================================ icon('heroicon-m-trash'), ]; } } ================================================ FILE: app/Filament/Resources/ReportResource/Pages/ListReports.php ================================================ icon('heroicon-s-pencil'), ]; } } ================================================ FILE: app/Filament/Resources/ReportResource.php ================================================ columns(1) ->schema([ Forms\Components\TextInput::make('name') ->label('Name') ->required() ->maxLength(255), Forms\Components\TextInput::make('description') ->label('Description') ->nullable() ->maxLength(255), Toggle::make('is_public') ->label('Is public?') ->required(), DateTimePicker::make('public_until') ->label('Public until') ->nullable(), Forms\Components\Select::make('organization_id') ->label('Organization') ->relationship(name: 'organization', titleAttribute: 'name') ->searchable(['name']) ->disabled() ->required(), Forms\Components\TextInput::make('share_secret') ->label('Share Secret') ->nullable(), PrettyJsonField::make('properties') ->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string { return $record->getRawOriginal('properties'); }) ->disabled(), Forms\Components\DateTimePicker::make('created_at') ->label('Created At') ->hiddenOn(['create']) ->disabled(), Forms\Components\DateTimePicker::make('updated_at') ->label('Updated At') ->hiddenOn(['create']) ->disabled(), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('description') ->searchable() ->sortable(), ToggleColumn::make('is_public') ->label('Is public?') ->sortable(), TextColumn::make('organization.name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable(), Tables\Columns\TextColumn::make('updated_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->defaultSort('created_at', 'desc') ->filters([ SelectFilter::make('organization') ->label('Organization') ->relationship('organization', 'name') ->searchable(), SelectFilter::make('organization_id') ->label('Organization ID') ->relationship('organization', 'id') ->searchable(), ]) ->actions([ Action::make('public-view') ->label('Public') ->icon('heroicon-o-eye') ->color('gray') ->hidden(fn (Report $record): bool => $record->getShareableLink() === null) ->url(fn (Report $record): string => $record->getShareableLink(), true), Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), Tables\Actions\DeleteAction::make(), ]) ->bulkActions([ ]); } public static function getRelations(): array { return [ ]; } public static function getPages(): array { return [ 'index' => Pages\ListReports::route('/'), 'edit' => Pages\EditReport::route('/{record}/edit'), 'view' => Pages\ViewReport::route('/{record}'), ]; } } ================================================ FILE: app/Filament/Resources/TagResource/Pages/CreateTag.php ================================================ icon('heroicon-m-trash'), ]; } } ================================================ FILE: app/Filament/Resources/TagResource/Pages/ListTags.php ================================================ icon('heroicon-s-plus'), ]; } } ================================================ FILE: app/Filament/Resources/TagResource.php ================================================ schema([ TextInput::make('name') ->label('Name') ->required(), Forms\Components\Select::make('organization_id') ->relationship(name: 'organization', titleAttribute: 'name') ->label('Organization') ->searchable(['name']) ->required(), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->label('Name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('organization.name') ->sortable() ->label('Organization'), Tables\Columns\TextColumn::make('created_at') ->label('Created at') ->sortable(), Tables\Columns\TextColumn::make('updated_at') ->label('Updated at') ->sortable(), ]) ->defaultSort('created_at', 'desc') ->filters([ SelectFilter::make('organization') ->label('Organization') ->relationship('organization', 'name') ->searchable(), SelectFilter::make('organization_id') ->label('Organization ID') ->relationship('organization', 'id') ->searchable(), ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } public static function getRelations(): array { return [ // ]; } public static function getPages(): array { return [ 'index' => Pages\ListTags::route('/'), 'create' => Pages\CreateTag::route('/create'), 'edit' => Pages\EditTag::route('/{record}/edit'), ]; } } ================================================ FILE: app/Filament/Resources/TaskResource/Pages/CreateTask.php ================================================ icon('heroicon-m-trash'), ]; } } ================================================ FILE: app/Filament/Resources/TaskResource/Pages/ListTasks.php ================================================ icon('heroicon-s-plus'), ]; } } ================================================ FILE: app/Filament/Resources/TaskResource.php ================================================ schema([ Forms\Components\TextInput::make('name') ->label('Name') ->required() ->maxLength(255), Select::make('project_id') ->relationship(name: 'project', titleAttribute: 'name') ->searchable(['name']) ->required(), Select::make('organization_id') ->relationship(name: 'organization', titleAttribute: 'name') ->searchable(['name']) ->required(), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('project.name') ->sortable(), Tables\Columns\TextColumn::make('organization.name') ->sortable(), Tables\Columns\TextColumn::make('created_at') ->sortable(), Tables\Columns\TextColumn::make('updated_at') ->sortable(), ]) ->filters([ SelectFilter::make('organization') ->label('Organization') ->relationship('organization', 'name') ->searchable(), SelectFilter::make('organization_id') ->label('Organization ID') ->relationship('organization', 'id') ->searchable(), ]) ->defaultSort('created_at', 'desc') ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } public static function getRelations(): array { return [ // ]; } public static function getPages(): array { return [ 'index' => Pages\ListTasks::route('/'), 'create' => Pages\CreateTask::route('/create'), 'edit' => Pages\EditTask::route('/{record}/edit'), ]; } } ================================================ FILE: app/Filament/Resources/TimeEntryResource/Pages/CreateTimeEntry.php ================================================ $data * @return array */ protected function mutateFormDataBeforeCreate(array $data): array { if (isset($data['member_id'])) { /** @var Member|null $member */ $member = Member::query()->find($data['member_id']); if ($member !== null) { $data['user_id'] = $member->user_id; $data['organization_id'] = $member->organization_id; } } return $data; } } ================================================ FILE: app/Filament/Resources/TimeEntryResource/Pages/EditTimeEntry.php ================================================ icon('heroicon-m-trash'), ]; } /** * @param array $data * @return array */ protected function mutateFormDataBeforeSave(array $data): array { if (isset($data['member_id'])) { /** @var Member|null $member */ $member = Member::query()->find($data['member_id']); if ($member !== null) { $data['user_id'] = $member->user_id; $data['organization_id'] = $member->organization_id; } } return $data; } } ================================================ FILE: app/Filament/Resources/TimeEntryResource/Pages/ListTimeEntries.php ================================================ icon('heroicon-s-plus'), ]; } } ================================================ FILE: app/Filament/Resources/TimeEntryResource.php ================================================ schema([ TextInput::make('id') ->label('ID') ->readOnly() ->disabled(), TextInput::make('description') ->label('Description') ->required() ->maxLength(255), Toggle::make('billable') ->label('Is Billable?') ->required(), DateTimePicker::make('start') ->label('Start') ->required(), DateTimePicker::make('end') ->label('End') ->nullable() ->rules([ 'after_or_equal:start', ]), Select::make('member_id') ->relationship( name: 'member', titleAttribute: 'id', modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization']) ) ->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')') ->searchable() ->required(), Select::make('project_id') ->relationship(name: 'project', titleAttribute: 'name') ->searchable(['name']) ->nullable(), Select::make('task_id') ->relationship(name: 'task', titleAttribute: 'name') ->searchable(['name']) ->nullable(), ]); } public static function table(Table $table): Table { return $table ->columns([ TextColumn::make('description') ->searchable() ->label('Description'), TextColumn::make('user.email') ->label('User'), TextColumn::make('project.name') ->label('Project'), TextColumn::make('task.name') ->label('Task'), TextColumn::make('time') ->getStateUsing(function (TimeEntry $record): string { return ($record->getDuration()?->cascade()?->forHumans() ?? '-').' '. ' ('.$record->start->toDateTimeString('minute').' - '. ($record->end?->toDateTimeString('minute') ?? '...').')'; }) ->label('Time'), Tables\Columns\TextColumn::make('organization.name') ->sortable(), Tables\Columns\TextColumn::make('created_at') ->sortable(), Tables\Columns\TextColumn::make('updated_at') ->sortable(), ]) ->filters([ SelectFilter::make('organization') ->label('Organization') ->relationship('organization', 'name') ->searchable(), SelectFilter::make('organization_id') ->label('Organization ID') ->relationship('organization', 'id') ->searchable(), ]) ->defaultSort('created_at', 'desc') ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } public static function getRelations(): array { return [ // ]; } public static function getPages(): array { return [ 'index' => Pages\ListTimeEntries::route('/'), 'create' => Pages\CreateTimeEntry::route('/create'), 'edit' => Pages\EditTimeEntry::route('/{record}/edit'), ]; } } ================================================ FILE: app/Filament/Resources/TokenResource/Pages/ListTokens.php ================================================ columns(1) ->schema([ Forms\Components\TextInput::make('id') ->label('ID') ->disabled() ->visibleOn(['update', 'show']) ->readOnly() ->maxLength(255), Forms\Components\TextInput::make('name') ->label('Name') ->required() ->maxLength(255), Forms\Components\Select::make('owner_id') ->label('User') ->relationship(name: 'user', titleAttribute: 'name') ->searchable(['name']) ->disabled() ->required(), Forms\Components\Select::make('client_id') ->label('Client') ->relationship(name: 'client', titleAttribute: 'name') ->searchable(['name']) ->required(), Forms\Components\Toggle::make('revoked') ->label('Revoked') ->required(), Forms\Components\DateTimePicker::make('expires_at') ->label('Expires At') ->disabled(), Forms\Components\DateTimePicker::make('created_at') ->label('Created At') ->disabled(), Forms\Components\DateTimePicker::make('updated_at') ->label('Updated At') ->disabled(), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('user.name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('client.name') ->searchable() ->sortable(), Tables\Columns\IconColumn::make('personal_access_client') ->state(function (Token $token): bool { return in_array('personal_access', $token->client->grant_types ?? [], true); }) ->boolean() ->label('API token?'), Tables\Columns\IconColumn::make('revoked') ->boolean() ->label('Revoked?') ->sortable(), Tables\Columns\TextColumn::make('expires_at') ->dateTime() ->sortable(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable(), Tables\Columns\TextColumn::make('updated_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->defaultSort('created_at', 'desc') ->filters([ TernaryFilter::make('is_personal_access_client') ->queries( true: function (Builder $query) { /** @var Builder $query */ return $query->isApiToken(); }, false: function (Builder $query) { /** @var Builder $query */ return $query->isApiToken(false); }, blank: function (Builder $query) { /** @var Builder $query */ return $query; }, ) ->label('API token?'), TernaryFilter::make('revoked') ->label('Revoked?'), ]) ->actions([ Tables\Actions\ViewAction::make(), ]) ->bulkActions([ ]); } public static function getRelations(): array { return [ ]; } public static function getPages(): array { return [ 'index' => Pages\ListTokens::route('/'), 'view' => Pages\ViewToken::route('/{record}'), ]; } } ================================================ FILE: app/Filament/Resources/UserResource/Actions/DeleteUser.php ================================================ icon('heroicon-m-trash'); $this->action(function (): void { $result = $this->process(function (User $record): bool { try { $deletionService = app(DeletionService::class); $deletionService->deleteUser($record); return true; } catch (ApiException $exception) { $this->failureNotificationTitle($exception->getTranslatedMessage()); report($exception); } catch (Throwable $exception) { $this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel')); report($exception); } return false; }); if (! $result) { $this->failure(); return; } $this->success(); }); } } ================================================ FILE: app/Filament/Resources/UserResource/Pages/CreateUser.php ================================================ createUser( $data['name'], $data['email'], $data['password_create'], $data['timezone'], Weekday::from($data['week_start']), $data['currency'], verifyEmail: (bool) $data['is_email_verified'] ); return $user; } } ================================================ FILE: app/Filament/Resources/UserResource/Pages/EditUser.php ================================================ record($this->getRecord()), UserResource\Actions\DeleteUser::make(), ]; } } ================================================ FILE: app/Filament/Resources/UserResource/Pages/ListUsers.php ================================================ icon('heroicon-s-plus'), ]; } } ================================================ FILE: app/Filament/Resources/UserResource/Pages/ViewUser.php ================================================ record($this->getRecord()), EditAction::make('edit') ->icon('heroicon-s-pencil'), ]; } } ================================================ FILE: app/Filament/Resources/UserResource/RelationManagers/OrganizationsRelationManager.php ================================================ schema([ Select::make('role') ->options(Role::class), ]); } public function table(Table $table): Table { return $table ->recordTitleAttribute('name') ->columns([ TextColumn::make('name'), TextColumn::make('role'), TextColumn::make('membership.billable_rate') ->label('Billable rate') ->money(fn (Organization $resource) => $resource->currency, divideBy: 100), ]) ->headerActions([ ]) ->actions([ Action::make('view') ->icon('heroicon-o-eye') ->color('gray') ->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [ 'record' => $record->getKey(), ])), Tables\Actions\EditAction::make() ->using(function (Organization $record, array $data): Organization { /** @var Member $member */ $member = $record->getRelation('membership'); if ($data['role'] !== $member->role) { try { app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true); } catch (ApiException $exception) { Notification::make() ->danger() ->title('Update failed') ->body($exception->getTranslatedMessage()) ->persistent() ->send(); } } $member->save(); return $record; }), Tables\Actions\DetachAction::make() ->using(function (Organization $record): void { /** @var User $user */ $user = $this->getOwnerRecord(); $member = Member::query() ->whereBelongsTo($user, 'user') ->whereBelongsTo($record, 'organization') ->firstOrFail(); try { app(MemberService::class)->removeMember($member, $record); } catch (ApiException $exception) { Notification::make() ->danger() ->title('Delete failed') ->body($exception->getTranslatedMessage()) ->persistent() ->send(); } }), ]) ->bulkActions([ ]); } } ================================================ FILE: app/Filament/Resources/UserResource/RelationManagers/OwnedOrganizationsRelationManager.php ================================================ schema([ ]); } public function table(Table $table): Table { return $table ->recordTitleAttribute('name') ->columns([ Tables\Columns\TextColumn::make('name'), ]) ->filters([ // ]) ->headerActions([ ]) ->actions([ Action::make('view') ->icon('heroicon-o-eye') ->color('gray') ->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [ 'record' => $record->getKey(), ])), Action::make('edit') ->icon('heroicon-o-pencil') ->url(fn (Organization $record): string => OrganizationResource::getUrl('edit', [ 'record' => $record->getKey(), ])) ->openUrlInNewTab(), ]) ->bulkActions([ ]); } } ================================================ FILE: app/Filament/Resources/UserResource.php ================================================ getRecord(); return $form ->columns(1) ->schema([ Forms\Components\TextInput::make('id') ->label('ID') ->disabled() ->visibleOn(['update', 'show']) ->readOnly() ->maxLength(255), Forms\Components\TextInput::make('name') ->label('Name') ->required() ->maxLength(255), Forms\Components\TextInput::make('email') ->label('Email') ->required() ->rules($record?->is_placeholder ? [] : [ UniqueEloquent::make(User::class, 'email') ->ignore($record?->getKey()), ]) ->rule([ 'email', ]) ->maxLength(255), Forms\Components\Toggle::make('is_placeholder') ->label('Is Placeholder?') ->hiddenOn(['create']) ->disabledOn(['edit']), Forms\Components\DateTimePicker::make('email_verified_at') ->label('Email Verified At') ->hiddenOn(['create']) ->nullable(), Forms\Components\Toggle::make('is_email_verified') ->label('Email Verified?') ->visibleOn(['create']), Forms\Components\Select::make('timezone') ->label('Timezone') ->options(fn (): array => app(TimezoneService::class)->getSelectOptions()) ->searchable() ->required(), Forms\Components\Select::make('week_start') ->label('Week Start') ->options(Weekday::class) ->required(), TextInput::make('password') ->password() ->label('Password') ->dehydrateStateUsing(fn ($state) => Hash::make($state)) ->dehydrated(fn ($state) => filled($state)) ->hiddenOn(['create']) ->required(fn (string $context): bool => $context === 'create') ->maxLength(255), TextInput::make('password_create') ->password() ->label('Password') ->visibleOn(['create']) ->required(fn (string $context): bool => $context === 'create') ->maxLength(255), Forms\Components\Select::make('currency') ->label('Currency (Personal Organization)') ->options(function (): array { $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); $select = []; foreach ($currencies as $currency) { $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')'; } return $select; }) ->required() ->visibleOn(['create']) ->searchable(), Forms\Components\DateTimePicker::make('created_at') ->label('Created At') ->hiddenOn(['create']) ->disabled(), Forms\Components\DateTimePicker::make('updated_at') ->label('Updated At') ->hiddenOn(['create']) ->disabled(), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('email') ->icon('heroicon-m-envelope') ->searchable() ->sortable(), Tables\Columns\IconColumn::make('is_real_user') ->getStateUsing(fn (User $record): bool => ! $record->is_placeholder) ->label('Real user?') ->boolean(), Tables\Columns\IconColumn::make('email_verified') ->getStateUsing(fn (User $record): bool => $record->email_verified_at !== null) ->label('Email verified?') ->boolean(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable(), Tables\Columns\TextColumn::make('updated_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->defaultSort('created_at', 'desc') ->filters([ TernaryFilter::make('real_user') ->queries( true: function (Builder $query): Builder { /** @var Builder $query */ return $query->where('is_placeholder', '=', false); }, false: function (Builder $query): Builder { /** @var Builder $query */ return $query->where('is_placeholder', '=', true); }, blank: function (Builder $query): Builder { /** @var Builder $query */ return $query; }, ) ->label('Real User?'), TernaryFilter::make('email_verified') ->label('Email Verified?') ->attribute('email_verified_at') ->nullable(), ]) ->actions([ Impersonate::make()->before(function (User $record): void { if ($record->currentTeam === null) { $organization = $record->organizations()->where('personal_team', '=', true)->first(); if ($organization === null) { $organization = $record->organizations()->first(); } if ($organization === null) { throw new Exception('User has no organization'); } $record->currentTeam()->associate($organization); $record->save(); } }), Tables\Actions\EditAction::make(), Tables\Actions\DeleteAction::make() ->hidden(fn (User $record) => $record->is(Auth::user())) ->using(function (User $record): void { try { app(DeletionService::class)->deleteUser($record); } catch (ApiException $exception) { Notification::make() ->danger() ->title('Delete failed') ->body($exception->getTranslatedMessage()) ->persistent() ->send(); } }), ]) ->bulkActions([ Tables\Actions\BulkAction::make('Resend verification email') ->icon('heroicon-o-paper-airplane') ->action(function (Collection $records): void { foreach ($records as $user) { /** @var User $user */ $user->sendEmailVerificationNotification(); } }), ]); } public static function getRelations(): array { return [ OrganizationsRelationManager::class, OwnedOrganizationsRelationManager::class, ]; } public static function getPages(): array { return [ 'index' => Pages\ListUsers::route('/'), 'create' => Pages\CreateUser::route('/create'), 'edit' => Pages\EditUser::route('/{record}/edit'), 'view' => Pages\ViewUser::route('/{record}'), ]; } } ================================================ FILE: app/Filament/Widgets/ActiveUserOverview.php ================================================ where('is_placeholder', '=', false)->count(); $placeholderUserCount = User::query()->where('is_placeholder', '=', true)->count(); $activeInLastWeek = User::query() ->where('is_placeholder', '=', false) ->whereHas('timeEntries', function (Builder $query): void { /** @var Builder $query */ $query->where('created_at', '>=', now()->subWeek()) ->orWhere('updated_at', '>=', now()->subWeek()); }) ->count(); return [ Stat::make('Total', $usersCount) ->color('primary') ->description('Total real users'), Stat::make('Placeholder', $placeholderUserCount) ->color('danger') ->description('Placeholder users'), Stat::make('Active', $activeInLastWeek) ->color('success') ->description('Active users in the last seven days'), ]; } } ================================================ FILE: app/Filament/Widgets/ServerOverview.php ================================================ */ protected function getViewData(): array { /** @var string|null $currentVersion */ $currentVersion = config('app.version'); /** @var string|null $build */ $build = config('app.build'); $latestVersion = Cache::get('latest_version', null); $needsUpdate = false; if ($latestVersion !== null && $currentVersion !== null && version_compare($latestVersion, $currentVersion) > 0) { $needsUpdate = true; } return [ 'version' => $currentVersion, 'build' => $build, 'environment' => config('app.env'), 'currentVersion' => $latestVersion, 'needsUpdate' => $needsUpdate, ]; } } ================================================ FILE: app/Filament/Widgets/TimeEntriesCreated.php ================================================ filter; if ($filter === 'week') { $start = now()->subWeek(); } elseif ($filter === 'month') { $start = now()->subMonth(); } elseif ($filter === 'year') { $start = now()->subYear(); } else { $start = now()->subWeek(); } $trend = Trend::query( TimeEntry::query()->where('is_imported', '=', false) ) ->between( start: $start, end: now(), ) ->perDay(); if ($filter === 'week') { $trend->perDay(); } elseif ($filter === 'month') { $trend->perDay(); } elseif ($filter === 'year') { $trend->perMonth(); } else { $trend->perDay(); } $data = $trend->count(); return [ 'datasets' => [ [ 'label' => self::$heading, 'data' => $data->map(fn (TrendValue $value) => $value->aggregate), ], ], 'labels' => $data->map(fn (TrendValue $value) => $value->date), ]; } protected function getFilters(): ?array { return [ 'week' => 'Last week', 'month' => 'Last month', 'year' => 'Last year', ]; } protected function getType(): string { return 'line'; } } ================================================ FILE: app/Filament/Widgets/TimeEntriesImported.php ================================================ filter; if ($filter === 'week') { $start = now()->subWeek(); } elseif ($filter === 'month') { $start = now()->subMonth(); } elseif ($filter === 'year') { $start = now()->subYear(); } else { $start = now()->subWeek(); } $trend = Trend::query( TimeEntry::query()->where('is_imported', '=', true) ) ->between( start: $start, end: now(), ) ->perDay(); if ($filter === 'week') { $trend->perDay(); } elseif ($filter === 'month') { $trend->perDay(); } elseif ($filter === 'year') { $trend->perMonth(); } else { $trend->perDay(); } $data = $trend->count(); return [ 'datasets' => [ [ 'label' => self::$heading, 'data' => $data->map(fn (TrendValue $value) => $value->aggregate), ], ], 'labels' => $data->map(fn (TrendValue $value) => $value->date), ]; } protected function getFilters(): ?array { return [ 'week' => 'Last week', 'month' => 'Last month', 'year' => 'Last year', ]; } protected function getType(): string { return 'line'; } } ================================================ FILE: app/Filament/Widgets/UserRegistrations.php ================================================ filter; if ($filter === 'week') { $start = now()->subWeek(); } elseif ($filter === 'month') { $start = now()->subMonth(); } elseif ($filter === 'year') { $start = now()->subYear(); } else { $start = now()->subWeek(); } $trend = Trend::query( User::query() ->where('is_placeholder', '=', false) ) ->between( start: $start, end: now(), ) ->perDay(); if ($filter === 'week') { $trend->perDay(); } elseif ($filter === 'month') { $trend->perDay(); } elseif ($filter === 'year') { $trend->perMonth(); } else { $trend->perDay(); } $data = $trend->count(); return [ 'datasets' => [ [ 'label' => self::$heading, 'data' => $data->map(fn (TrendValue $value) => $value->aggregate), ], ], 'labels' => $data->map(fn (TrendValue $value) => $value->date), ]; } protected function getFilters(): ?array { return [ 'week' => 'Last week', 'month' => 'Last month', 'year' => 'Last year', ]; } protected function getType(): string { return 'line'; } } ================================================ FILE: app/Http/Controllers/Api/V1/ApiTokenController.php ================================================ user(); $tokens = $user->tokens() ->whereHas('client', function (Builder $query): void { /** @var Builder $query */ $query->whereJsonContains('grant_types', 'personal_access'); }) ->orderBy('created_at', 'desc') ->get(); return new ApiTokenCollection($tokens); } /** * Create a new api token for the currently authenticated user * * The response will contain the access token that can be used to send authenticated API requests. * Please note that the access token is only shown in this response and cannot be retrieved later. * * @operationId createApiToken * * @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException */ public function store(ApiTokenStoreRequest $request): ApiTokenWithAccessTokenResource { $user = $this->user(); try { $token = $user->createToken($request->getName(), ['*']); /** @var Token $tokenModel */ $tokenModel = $token->getToken(); return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken); } catch (\RuntimeException $exception) { report($exception); if (Str::contains($exception->getMessage(), ['Personal access client not found'])) { throw new PersonalAccessClientIsNotConfiguredException; } throw $exception; } } /** * Revoke an api token * * @operationId revokeApiToken * * @throws AuthorizationException * @throws PersonalAccessClientIsNotConfiguredException */ public function revoke(Token $apiToken): JsonResponse { $user = $this->user(); if ($apiToken->user_id !== $user->getKey()) { throw new AuthorizationException('API token does not belong to user'); } if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) { throw new AuthorizationException('API token is not a personal access token'); } $apiToken->revoke(); return response()->json(null, 204); } /** * Delete an api token * * @operationId deleteApiToken * * @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException */ public function destroy(Token $apiToken): JsonResponse { $user = $this->user(); if ($apiToken->user_id !== $user->getKey()) { throw new AuthorizationException('API token does not belong to user'); } if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) { throw new AuthorizationException('API token is not a personal access token'); } $apiToken->delete(); return response()->json(null, 204); } } ================================================ FILE: app/Http/Controllers/Api/V1/ChartController.php ================================================ */ public function weeklyProjectOverview(Organization $organization, DashboardService $dashboardService): JsonResponse { $this->checkPermission($organization, 'charts:view:own'); $user = $this->user(); $weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization); return response()->json($weeklyProjectOverview); } /** * Get chart data for the latest tasks. * * @throws AuthorizationException * * @operationId latestTasks * * @response array */ public function latestTasks(Organization $organization, DashboardService $dashboardService): JsonResponse { $this->checkPermission($organization, 'charts:view:own'); $user = $this->user(); $latestTasks = $dashboardService->latestTasks($user, $organization); return response()->json($latestTasks); } /** * Get chart data for the last seven days. * * @throws AuthorizationException * * @operationId lastSevenDays * * @response array }> */ public function lastSevenDays(Organization $organization, DashboardService $dashboardService): JsonResponse { $this->checkPermission($organization, 'charts:view:own'); $user = $this->user(); $lastSevenDays = $dashboardService->lastSevenDays($user, $organization); return response()->json($lastSevenDays); } /** * Get chart data for the latest team activity. * * @throws AuthorizationException * * @operationId latestTeamActivity * * @response array */ public function latestTeamActivity(Organization $organization, DashboardService $dashboardService, PermissionStore $permissionStore): JsonResponse { $this->checkPermission($organization, 'charts:view:all'); $latestTeamActivity = $dashboardService->latestTeamActivity($organization); return response()->json($latestTeamActivity); } /** * Get chart data for daily tracked hours. * * @throws AuthorizationException * * @operationId dailyTrackedHours * * @response array */ public function dailyTrackedHours(Organization $organization, DashboardService $dashboardService): JsonResponse { $this->checkPermission($organization, 'charts:view:own'); $user = $this->user(); $dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 100); return response()->json($dailyTrackedHours); } /** * Get chart data for total weekly time. * * @throws AuthorizationException * * @operationId totalWeeklyTime * * @response int */ public function totalWeeklyTime(Organization $organization, DashboardService $dashboardService): JsonResponse { $this->checkPermission($organization, 'charts:view:own'); $user = $this->user(); $totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization); return response()->json($totalWeeklyTime); } /** * Get chart data for total weekly billable time. * * @throws AuthorizationException * * @operationId totalWeeklyBillableTime * * @response int */ public function totalWeeklyBillableTime(Organization $organization, DashboardService $dashboardService): JsonResponse { $this->checkPermission($organization, 'charts:view:own'); $user = $this->user(); $totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization); return response()->json($totalWeeklyBillableTime); } /** * Get chart data for total weekly billable amount. * * @throws AuthorizationException * * @operationId totalWeeklyBillableAmount * * @response array{value: int, currency: string} */ public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse { $this->checkPermission($organization, 'charts:view:own'); $user = $this->user(); $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; if (! $showBillableRate) { throw new AuthorizationException('You do not have permission to view billable rates.'); } $totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization); return response()->json($totalWeeklyBillableAmount); } /** * Get chart data for weekly history. * * @throws AuthorizationException * * @operationId weeklyHistory * * @response array */ public function weeklyHistory(Organization $organization, DashboardService $dashboardService): JsonResponse { $this->checkPermission($organization, 'charts:view:own'); $user = $this->user(); $weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization); return response()->json($weeklyHistory); } } ================================================ FILE: app/Http/Controllers/Api/V1/ClientController.php ================================================ organization_id !== $organization->getKey()) { throw new AuthorizationException('Tag does not belong to organization'); } } /** * Get clients * * @return ClientCollection * * @throws AuthorizationException * * @operationId getClients */ public function index(Organization $organization, ClientIndexRequest $request): ClientCollection { $this->checkPermission($organization, 'clients:view'); $canViewAllClients = $this->hasPermission($organization, 'clients:view:all'); $user = $this->user(); $clientsQuery = Client::query() ->whereBelongsTo($organization, 'organization') ->orderBy('created_at', 'desc'); if (! $canViewAllClients) { $clientsQuery->visibleByEmployee($user); } $filterArchived = $request->getFilterArchived(); if ($filterArchived === 'true') { $clientsQuery->whereNotNull('archived_at'); } elseif ($filterArchived === 'false') { $clientsQuery->whereNull('archived_at'); } $clients = $clientsQuery->paginate(config('app.pagination_per_page_default')); return new ClientCollection($clients); } /** * Create client * * @throws AuthorizationException * * @operationId createClient */ public function store(Organization $organization, ClientStoreRequest $request): ClientResource { $this->checkPermission($organization, 'clients:create'); $client = new Client; $client->name = $request->input('name'); $client->organization()->associate($organization); $client->save(); return new ClientResource($client); } /** * Update client * * @throws AuthorizationException * * @operationId updateClient */ public function update(Organization $organization, Client $client, ClientUpdateRequest $request): ClientResource { $this->checkPermission($organization, 'clients:update', $client); $client->name = $request->input('name'); if ($request->has('is_archived')) { $client->archived_at = $request->getIsArchived() ? Carbon::now() : null; } $client->save(); return new ClientResource($client); } /** * Delete client * * @throws AuthorizationException|EntityStillInUseApiException * * @operationId deleteClient */ public function destroy(Organization $organization, Client $client): JsonResponse { $this->checkPermission($organization, 'clients:delete', $client); if ($client->projects()->exists()) { throw new EntityStillInUseApiException('client', 'project'); } $client->delete(); return response()->json(null, 204); } } ================================================ FILE: app/Http/Controllers/Api/V1/Controller.php ================================================ permissionStore->has($organization, $permission)) { throw new AuthorizationException; } } /** * @param array $permissions * * @throws AuthorizationException */ protected function checkAnyPermission(Organization $organization, array $permissions): void { foreach ($permissions as $permission) { if ($this->permissionStore->has($organization, $permission)) { return; } } throw new AuthorizationException; } protected function hasPermission(Organization $organization, string $permission): bool { return $this->permissionStore->has($organization, $permission); } protected function canAccessPremiumFeatures(Organization $organization): bool { return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization); } } ================================================ FILE: app/Http/Controllers/Api/V1/CurrencyController.php ================================================ [ 'code' => $currency->getCurrencyCode(), 'name' => $currency->getName(), 'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()), ], ISOCurrencyProvider::getInstance()->getAvailableCurrencies() )); return response()->json($currencies); } } ================================================ FILE: app/Http/Controllers/Api/V1/ExportController.php ================================================ checkPermission($organization, 'export'); $filepath = $exportService->export($organization); $downloadUrl = Storage::disk(config('filesystems.private')) ->temporaryUrl($filepath, Carbon::now()->addMinutes(10)); return new JsonResponse([ 'success' => true, 'download_url' => $downloadUrl, ], 200); } } ================================================ FILE: app/Http/Controllers/Api/V1/ImportController.php ================================================ } */ public function index(Organization $organization, ImporterProvider $importerProvider): JsonResponse { $this->checkPermission($organization, 'import'); $importers = $importerProvider->getImporters(); /** @var array $importersResponse */ $importersResponse = []; foreach ($importers as $key => $importerClass) { /** @var ImporterContract $importer */ $importer = new $importerClass; $importersResponse[] = [ 'key' => $key, 'name' => $importer->getName(), 'description' => $importer->getDescription(), ]; } return new JsonResponse([ 'data' => $importersResponse, ], 200); } /** * Import data into the organization * * @throws AuthorizationException * * @operationId importData */ public function import(Organization $organization, ImportRequest $request, ImportService $importService): JsonResponse { $this->checkPermission($organization, 'import'); try { $importData = base64_decode($request->input('data'), true); if ($importData === false) { return new JsonResponse([ 'message' => 'Invalid base64 encoded data', ], 400); } $timezone = $this->user()->timezone; $report = $importService->import( $organization, $request->input('type'), $importData, $timezone ); return new JsonResponse([ /** @var array{ * clients: array{ * created: int, * }, * projects: array{ * created: int, * }, * tasks: array{ * created: int, * }, * time_entries: array{ * created: int, * }, * tags: array{ * created: int, * }, * users: array{ * created: int, * } * } $report Import report */ 'report' => $report->toArray(), ], 200); } catch (ImportException $exception) { report($exception); return new JsonResponse([ 'message' => $exception->getMessage(), ], 400); } } } ================================================ FILE: app/Http/Controllers/Api/V1/InvitationController.php ================================================ organization_id !== $organization->id) { throw new AuthorizationException('Invitation does not belong to organization'); } } /** * List all invitations of an organization * * @return InvitationCollection * * @throws AuthorizationException * * @operationId getInvitations */ public function index(Organization $organization, InvitationIndexRequest $request): InvitationCollection { $this->checkPermission($organization, 'invitations:view'); $invitations = $organization->teamInvitations() ->orderBy('created_at', 'desc') ->paginate(config('app.pagination_per_page_default')); return InvitationCollection::make($invitations); } /** * Invite a user to the organization * * @throws AuthorizationException * @throws UserIsAlreadyMemberOfOrganizationApiException * @throws InvitationForTheEmailAlreadyExistsApiException * * @operationId invite */ public function store(Organization $organization, InvitationStoreRequest $request, InvitationService $invitationService): JsonResponse { $this->checkPermission($organization, 'invitations:create'); $email = $request->getEmail(); $role = $request->getRole(); $invitationService->inviteUser($organization, $email, $role); return response()->json(null, 204); } /** * Resend email for a pending invitation * * @throws AuthorizationException * * @operationId resendInvitationEmail */ public function resend(Organization $organization, OrganizationInvitation $invitation, OrganizationInvitationService $organizationInvitationService): JsonResponse { $this->checkPermission($organization, 'invitations:resend', $invitation); $organizationInvitationService->resend($invitation); return response()->json(null, 204); } /** * Remove a pending invitation * * @throws AuthorizationException * * @operationId removeInvitation */ public function destroy(Organization $organization, OrganizationInvitation $invitation): JsonResponse { $this->checkPermission($organization, 'invitations:remove', $invitation); $invitation->delete(); return response()->json(null, 204); } } ================================================ FILE: app/Http/Controllers/Api/V1/MemberController.php ================================================ organization_id !== $organization->id) { throw new AuthorizationException('Member does not belong to organization'); } } /** * List all members of an organization * * @return MemberCollection * * @throws AuthorizationException * * @operationId getMembers */ public function index(Organization $organization, MemberIndexRequest $request): MemberCollection { $this->checkPermission($organization, 'members:view'); $members = Member::query() ->whereBelongsTo($organization, 'organization') ->with(['user']) ->orderBy('created_at', 'desc') ->paginate(config('app.pagination_per_page_default')); return MemberCollection::make($members); } /** * Update a member of the organization * * @throws AuthorizationException * @throws OrganizationNeedsAtLeastOneOwner * @throws OnlyOwnerCanChangeOwnership * @throws ChangingRoleToPlaceholderIsNotAllowed * @throws ChangingRoleOfPlaceholderIsNotAllowed * * @operationId updateMember */ public function update(Organization $organization, Member $member, MemberUpdateRequest $request, BillableRateService $billableRateService, MemberService $memberService): JsonResource { $this->checkPermission($organization, 'members:update', $member); if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) { $member->billable_rate = $request->getBillableRate(); $billableRateService->updateTimeEntriesBillableRateForMember($member); } if ($request->has('role') && $member->role !== $request->getRole()->value) { $newRole = $request->getRole(); $allowOwnerChange = $this->hasPermission($organization, 'members:change-ownership'); $memberService->changeRole($member, $organization, $newRole, $allowOwnerChange); } $member->save(); return new MemberResource($member); } /** * Remove a member of the organization. * * @throws AuthorizationException|EntityStillInUseApiException|CanNotRemoveOwnerFromOrganization * * @operationId removeMember */ public function destroy(MemberDestroyRequest $request, Organization $organization, Member $member, MemberService $memberService): JsonResponse { $this->checkPermission($organization, 'members:delete', $member); $deleteRelated = $request->getDeleteRelated(); $memberService->removeMember($member, $organization, $deleteRelated); return response() ->json(null, 204); } /** * Make a member a placeholder member * * @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed * * @operationId makePlaceholder */ public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse { $this->checkPermission($organization, 'members:make-placeholder', $member); if ($member->role === Role::Owner->value) { throw new CanNotRemoveOwnerFromOrganization; } if ($member->role === Role::Placeholder->value) { throw new ChangingRoleOfPlaceholderIsNotAllowed; } $memberService->makeMemberToPlaceholder($member); MemberMadeToPlaceholder::dispatch($member, $organization); return response()->json(null, 204); } /** * Merge one member into another * * @throws AuthorizationException * @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember * @throws \Throwable * * @operationId mergeMember */ public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request, MemberService $memberService): JsonResponse { $this->checkPermission($organization, 'members:merge-into', $member); $user = $member->user; if ($member->role !== Role::Placeholder->value || ! $user->is_placeholder) { throw new OnlyPlaceholdersCanBeMergedIntoAnotherMember; } $memberTo = Member::findOrFail($request->getMemberId()); DB::transaction(function () use ($organization, $member, $user, $memberTo, $memberService): void { $memberService->assignOrganizationEntitiesToDifferentMember($organization, $member, $memberTo); $member->delete(); $user->delete(); }); return response()->json(null, 204); } /** * Invite a placeholder member to become a real member of the organization * * @throws AuthorizationException * @throws UserNotPlaceholderApiException * @throws UserIsAlreadyMemberOfOrganizationApiException * @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException * @throws InvitationForTheEmailAlreadyExistsApiException * * @operationId invitePlaceholder */ public function invitePlaceholder(Organization $organization, Member $member, InvitationService $invitationService): JsonResponse { $this->checkPermission($organization, 'members:invite-placeholder', $member); $user = $member->user; if (! $user->is_placeholder) { throw new UserNotPlaceholderApiException; } if (Str::endsWith($user->email, '@solidtime-import.test')) { throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException; } $invitationService->inviteUser($organization, $user->email, Role::Employee); return response()->json(null, 204); } } ================================================ FILE: app/Http/Controllers/Api/V1/OrganizationController.php ================================================ checkPermission($organization, 'organizations:view'); $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; return new OrganizationResource($organization, $showBillableRate); } /** * Update organization * * @operationId updateOrganization * * @throws AuthorizationException */ public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource { $this->checkPermission($organization, 'organizations:update'); if ($request->getName() !== null) { $organization->name = $request->getName(); } if ($request->getEmployeesCanSeeBillableRates() !== null) { $organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates(); } if ($request->getEmployeesCanManageTasks() !== null) { $organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks(); } if ($request->getNumberFormat() !== null) { $organization->number_format = $request->getNumberFormat(); } if ($request->getCurrencyFormat() !== null) { $organization->currency_format = $request->getCurrencyFormat(); } if ($request->getDateFormat() !== null) { $organization->date_format = $request->getDateFormat(); } if ($request->getIntervalFormat() !== null) { $organization->interval_format = $request->getIntervalFormat(); } if ($request->getTimeFormat() !== null) { $organization->time_format = $request->getTimeFormat(); } if ($request->getPreventOverlappingTimeEntries() !== null) { $organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries(); } $hasBillableRate = $request->has('billable_rate'); if ($hasBillableRate) { $oldBillableRate = $organization->billable_rate; $organization->billable_rate = $request->getBillableRate(); } $organization->save(); if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) { $billableRateService->updateTimeEntriesBillableRateForOrganization($organization); } return new OrganizationResource($organization, true); } } ================================================ FILE: app/Http/Controllers/Api/V1/ProjectController.php ================================================ organization_id !== $organization->id) { throw new AuthorizationException('Project does not belong to organization'); } } /** * Get projects visible to the current user * * @return ProjectCollection * * @throws AuthorizationException * * @operationId getProjects */ public function index(Organization $organization, ProjectIndexRequest $request): ProjectCollection { $this->checkPermission($organization, 'projects:view'); $canViewAllProjects = $this->hasPermission($organization, 'projects:view:all'); $user = $this->user(); $projectsQuery = Project::query() ->whereBelongsTo($organization, 'organization'); if (! $canViewAllProjects) { $projectsQuery->visibleByEmployee($user); } $filterArchived = $request->getFilterArchived(); if ($filterArchived === 'true') { $projectsQuery->whereNotNull('archived_at'); } elseif ($filterArchived === 'false') { $projectsQuery->whereNull('archived_at'); } $projects = $projectsQuery ->orderBy('created_at', 'desc') ->paginate(config('app.pagination_per_page_default')); $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; return new ProjectCollection($projects, $showBillableRate); } /** * Get project * * @throws AuthorizationException * * @operationId getProject */ public function show(Organization $organization, Project $project): JsonResource { $this->checkPermission($organization, 'projects:view:all', $project); // Note: There is currently no need to check if a user is a member of the project, // since this is only relevant for users with the role "employee" and they can not access this endpoint. $project->load('organization'); return new ProjectResource($project, true); } /** * Create project * * @throws AuthorizationException * * @operationId createProject */ public function store(Organization $organization, ProjectStoreRequest $request): JsonResource { $this->checkPermission($organization, 'projects:create'); $project = new Project; $project->name = $request->input('name'); $project->color = $request->input('color'); $project->is_billable = (bool) $request->input('is_billable'); $project->billable_rate = $request->getBillableRate(); $project->client_id = $request->input('client_id'); $project->is_public = $request->getIsPublic(); if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { $project->estimated_time = $request->getEstimatedTime(); } $project->organization()->associate($organization); $project->save(); return new ProjectResource($project, true); } /** * Update project * * @throws AuthorizationException * * @operationId updateProject */ public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'projects:update', $project); $project->name = $request->input('name'); $project->color = $request->input('color'); $project->is_billable = (bool) $request->input('is_billable'); if ($request->has('is_archived')) { $project->archived_at = $request->getIsArchived() ? Carbon::now() : null; } if ($request->has('is_public')) { $project->is_public = $request->boolean('is_public'); } if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { $project->estimated_time = $request->getEstimatedTime(); } $oldBillableRate = $project->billable_rate; $clientIdChanged = false; $project->billable_rate = $request->getBillableRate(); if ($project->client_id !== $request->input('client_id')) { $project->client_id = $request->input('client_id'); $clientIdChanged = true; } $project->save(); if ($oldBillableRate !== $request->getBillableRate()) { $billableRateService->updateTimeEntriesBillableRateForProject($project); } if ($clientIdChanged) { TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->whereBelongsTo($project, 'project') ->update(['client_id' => $project->client_id]); } return new ProjectResource($project, true); } /** * Delete project * * @throws AuthorizationException|EntityStillInUseApiException * * @operationId deleteProject */ public function destroy(Organization $organization, Project $project): JsonResponse { $this->checkPermission($organization, 'projects:delete', $project); if ($project->tasks()->exists()) { throw new EntityStillInUseApiException('project', 'task'); } if ($project->timeEntries()->exists()) { throw new EntityStillInUseApiException('project', 'time_entry'); } DB::transaction(function () use (&$project): void { $project->members->each(function (ProjectMember $member): void { $member->delete(); }); $project->delete(); }); return response() ->json(null, 204); } } ================================================ FILE: app/Http/Controllers/Api/V1/ProjectMemberController.php ================================================ organization_id !== $organization->id) { throw new AuthorizationException('Project does not belong to organization'); } if ($projectMember !== null && $projectMember->project->organization_id !== $organization->id) { throw new AuthorizationException('Project member does not belong to organization'); } } /** * Get project members for project * * @return ProjectMemberCollection * * @throws AuthorizationException * * @operationId getProjectMembers */ public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection { $this->checkPermission($organization, 'project-members:view', $project); $projectMembers = ProjectMember::query() ->whereBelongsTo($project, 'project') ->orderBy('created_at', 'desc') ->paginate(config('app.pagination_per_page_default')); return new ProjectMemberCollection($projectMembers); } /** * Add project member to project * * @throws AuthorizationException|InactiveUserCanNotBeUsedApiException|UserIsAlreadyMemberOfProjectApiException * * @operationId createProjectMember */ public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'project-members:create', $project); $member = Member::findOrFail((string) $request->input('member_id')); if ($member->user->is_placeholder) { throw new InactiveUserCanNotBeUsedApiException; } if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($member, 'member')->exists()) { throw new UserIsAlreadyMemberOfProjectApiException; } $projectMember = new ProjectMember; $projectMember->billable_rate = $request->getBillableRate(); $projectMember->member()->associate($member); $projectMember->user()->associate($member->user); $projectMember->project()->associate($project); $projectMember->save(); if ($request->getBillableRate() !== null) { $billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember); } return new ProjectMemberResource($projectMember); } /** * Update project member * * @throws AuthorizationException * * @operationId updateProjectMember */ public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'project-members:update', projectMember: $projectMember); $oldBillableRate = $projectMember->billable_rate; $projectMember->billable_rate = $request->getBillableRate(); $projectMember->save(); if ($oldBillableRate !== $request->getBillableRate()) { $billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember); } return new ProjectMemberResource($projectMember); } /** * Delete project member * * @throws AuthorizationException * * @operationId deleteProjectMember */ public function destroy(Organization $organization, ProjectMember $projectMember, BillableRateService $billableRateService): JsonResponse { $this->checkPermission($organization, 'project-members:delete', projectMember: $projectMember); $hadBillableRate = $projectMember->billable_rate !== null; $project = $projectMember->project; $member = $projectMember->member; $projectMember->delete(); if ($hadBillableRate) { $billableRateService->updateTimeEntriesBillableRateForMember($member); $billableRateService->updateTimeEntriesBillableRateForProject($project); $billableRateService->updateTimeEntriesBillableRateForOrganization($organization); } return response() ->json(null, 204); } } ================================================ FILE: app/Http/Controllers/Api/V1/Public/ReportController.php ================================================ header('X-Api-Key'); if (! is_string($shareSecret)) { throw new ModelNotFoundException; } $report = Report::query() ->with([ 'organization', ]) ->where('share_secret', '=', $shareSecret) ->where('is_public', '=', true) ->where(function (Builder $builder): void { /** @var Builder $builder */ $builder->whereNull('public_until') ->orWhere('public_until', '>', now()); }) ->firstOrFail(); /** @var ReportPropertiesDto $properties */ $properties = $report->properties; $timeEntriesQuery = TimeEntry::query() ->whereBelongsTo($report->organization, 'organization'); $filter = new TimeEntryFilter($timeEntriesQuery); $filter->addStart($properties->start); $filter->addEnd($properties->end); $filter->addActive($properties->active); $filter->addBillable($properties->billable); $filter->addMemberIdsFilter($properties->memberIds?->toArray()); $filter->addProjectIdsFilter($properties->projectIds?->toArray()); $filter->addTagIdsFilter($properties->tagIds?->toArray()); $filter->addTaskIdsFilter($properties->taskIds?->toArray()); $filter->addClientIdsFilter($properties->clientIds?->toArray()); $timeEntriesQuery = $filter->get(); $data = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $timeEntriesQuery->clone(), $report->properties->group, $report->properties->subGroup, $report->properties->timezone, $report->properties->weekStart, false, $report->properties->start, $report->properties->end, true, $report->properties->roundingType, $report->properties->roundingMinutes, ); $historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $timeEntriesQuery->clone(), TimeEntryAggregationType::fromInterval($report->properties->historyGroup), null, $report->properties->timezone, $report->properties->weekStart, true, $report->properties->start, $report->properties->end, true, $report->properties->roundingType, $report->properties->roundingMinutes, ); return new DetailedWithDataReportResource($report, $data, $historyData); } } ================================================ FILE: app/Http/Controllers/Api/V1/ReportController.php ================================================ organization_id !== $organization->id) { throw new AuthorizationException('Report does not belong to organization'); } } /** * Get reports * * @return ReportCollection * * @throws AuthorizationException * * @operationId getReports */ public function index(Organization $organization, ReportIndexRequest $request): ReportCollection { $this->checkPermission($organization, 'reports:view'); $reports = Report::query() ->orderBy('created_at', 'desc') ->whereBelongsTo($organization, 'organization') ->paginate(config('app.pagination_per_page_default')); return new ReportCollection($reports); } /** * Get report * * @throws AuthorizationException * * @operationId getReport */ public function show(Organization $organization, Report $report): DetailedReportResource { $this->checkPermission($organization, 'reports:view', $report); return new DetailedReportResource($report); } /** * Create report * * @throws AuthorizationException * * @operationId createReport */ public function store(Organization $organization, ReportStoreRequest $request, TimezoneService $timezoneService, ReportService $reportService): DetailedReportResource { $this->checkPermission($organization, 'reports:create'); $user = $this->user(); $report = new Report; $report->name = $request->getName(); $report->description = $request->getDescription(); $isPublic = $request->getIsPublic(); $report->is_public = $isPublic; $properties = new ReportPropertiesDto; $properties->group = $request->getPropertyGroup(); $properties->subGroup = $request->getPropertySubGroup(); $properties->historyGroup = $request->getPropertyHistoryGroup(); $properties->start = $request->getPropertyStart(); $properties->end = $request->getPropertyEnd(); $properties->active = $request->getPropertyActive(); $properties->setMemberIds($request->input('properties.member_ids', null)); $properties->billable = $request->getPropertyBillable(); $properties->setClientIds($request->input('properties.client_ids', null)); $properties->setProjectIds($request->input('properties.project_ids', null)); $properties->setTagIds($request->input('properties.tag_ids', null)); $properties->setTaskIds($request->input('properties.task_ids', null)); $properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start; $timezone = $user->timezone; if ($request->has('properties.timezone')) { if ($timezoneService->isValid($request->input('properties.timezone'))) { $timezone = $request->input('properties.timezone'); } if ($timezoneService->mapLegacyTimezone($request->input('properties.timezone')) !== null) { $timezone = $timezoneService->mapLegacyTimezone($request->input('properties.timezone')); } } $properties->timezone = $timezone; $properties->roundingType = $request->getPropertyRoundingType(); $properties->roundingMinutes = $request->getPropertyRoundingMinutes(); $report->properties = $properties; if ($isPublic) { $report->share_secret = $reportService->generateSecret(); $report->public_until = $request->getPublicUntil(); } else { $report->share_secret = null; $report->public_until = null; } $report->organization()->associate($organization); $report->save(); return new DetailedReportResource($report); } /** * Update report * * @throws AuthorizationException * * @operationId updateReport */ public function update(Organization $organization, Report $report, ReportUpdateRequest $request, ReportService $reportService): DetailedReportResource { $this->checkPermission($organization, 'reports:update', $report); if ($request->has('name')) { $report->name = $request->getName(); } if ($request->has('description')) { $report->description = $request->getDescription(); } if ($request->has('is_public') && $request->getIsPublic() !== $report->is_public) { $isPublic = $request->getIsPublic(); $report->is_public = $isPublic; if ($isPublic) { $report->share_secret = $reportService->generateSecret(); $report->public_until = $request->getPublicUntil(); } else { $report->share_secret = null; $report->public_until = null; } } elseif ($report->is_public && $request->has('public_until')) { // Allow updating expiration date on already-public reports $report->public_until = $request->getPublicUntil(); } $report->save(); return new DetailedReportResource($report); } /** * Delete report * * @throws AuthorizationException * * @operationId deleteReport */ public function destroy(Organization $organization, Report $report): JsonResponse { $this->checkPermission($organization, 'reports:delete', $report); $report->delete(); return response()->json(null, 204); } } ================================================ FILE: app/Http/Controllers/Api/V1/TagController.php ================================================ organization_id !== $organization->getKey()) { throw new AuthorizationException('Tag does not belong to organization'); } } /** * Get tags * * @return TagCollection * * @operationId getTags * * @throws AuthorizationException */ public function index(Organization $organization, TagIndexRequest $request): TagCollection { $this->checkPermission($organization, 'tags:view'); $tags = Tag::query() ->whereBelongsTo($organization, 'organization') ->orderBy('created_at', 'desc') ->paginate(config('app.pagination_per_page_default')); return new TagCollection($tags); } /** * Create tag * * @throws AuthorizationException * * @operationId createTag */ public function store(Organization $organization, TagStoreRequest $request): TagResource { $this->checkPermission($organization, 'tags:create'); $tag = new Tag; $tag->name = $request->input('name'); $tag->organization()->associate($organization); $tag->save(); return new TagResource($tag); } /** * Update tag * * @throws AuthorizationException * * @operationId updateTag */ public function update(Organization $organization, Tag $tag, TagUpdateRequest $request): TagResource { $this->checkPermission($organization, 'tags:update', $tag); $tag->name = $request->input('name'); $tag->save(); return new TagResource($tag); } /** * Delete tag * * @throws AuthorizationException|EntityStillInUseApiException * * @operationId deleteTag */ public function destroy(Organization $organization, Tag $tag): JsonResponse { $this->checkPermission($organization, 'tags:delete', $tag); if (TimeEntry::query()->hasTag($tag)->whereBelongsTo($organization, 'organization')->exists()) { throw new EntityStillInUseApiException('tag', 'time_entry'); } $tag->delete(); return response()->json(null, 204); } } ================================================ FILE: app/Http/Controllers/Api/V1/TaskController.php ================================================ organization_id !== $organization->id) { throw new AuthorizationException('Task does not belong to organization'); } } /** * Check scoped permission and verify user has access to the project * * @throws AuthorizationException */ private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void { $this->checkPermission($organization, $permission); $user = $this->user(); $hasAccess = Project::query() ->where('id', $project->id) ->visibleByEmployee($user) ->exists(); if (! $hasAccess) { throw new AuthorizationException('You do not have permission to '.$permission.' in this project.'); } } /** * Get tasks * * @return TaskCollection * * @throws AuthorizationException * * @operationId getTasks */ public function index(Organization $organization, TaskIndexRequest $request): TaskCollection { $this->checkPermission($organization, 'tasks:view'); $canViewAllTasks = $this->hasPermission($organization, 'tasks:view:all'); $user = $this->user(); $projectId = $request->input('project_id'); $query = Task::query() ->whereBelongsTo($organization, 'organization'); if ($projectId !== null) { $query->where('project_id', '=', $projectId); } if (! $canViewAllTasks) { $query->visibleByEmployee($user); } $doneFilter = $request->getFilterDone(); if ($doneFilter === 'true') { $query->whereNotNull('done_at'); } elseif ($doneFilter === 'false') { $query->whereNull('done_at'); } $tasks = $query ->orderBy('created_at', 'desc') ->paginate(config('app.pagination_per_page_default')); return new TaskCollection($tasks); } /** * Create task * * @throws AuthorizationException * * @operationId createTask */ public function store(Organization $organization, TaskStoreRequest $request): JsonResource { /** @var Project $project */ $project = Project::query()->findOrFail($request->input('project_id')); if ($this->hasPermission($organization, 'tasks:create:all')) { $this->checkPermission($organization, 'tasks:create:all'); } else { $this->checkScopedPermissionForProject($organization, $project, 'tasks:create'); } $task = new Task; $task->name = $request->input('name'); $task->project_id = $request->input('project_id'); if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { $task->estimated_time = $request->getEstimatedTime(); } $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 { // Check task belongs to organization if ($task->organization_id !== $organization->id) { throw new AuthorizationException('Task does not belong to organization'); } if ($this->hasPermission($organization, 'tasks:update:all')) { $this->checkPermission($organization, 'tasks:update:all'); } else { $this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update'); } $task->name = $request->input('name'); if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { $task->estimated_time = $request->getEstimatedTime(); } if ($request->has('is_done')) { $task->done_at = $request->getIsDone() ? Carbon::now() : null; } $task->save(); return new TaskResource($task); } /** * Delete task * * @throws AuthorizationException|EntityStillInUseApiException * * @operationId deleteTask */ public function destroy(Organization $organization, Task $task): JsonResponse { // Check task belongs to organization if ($task->organization_id !== $organization->id) { throw new AuthorizationException('Task does not belong to organization'); } if ($this->hasPermission($organization, 'tasks:delete:all')) { $this->checkPermission($organization, 'tasks:delete:all'); } else { $this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete'); } if ($task->timeEntries()->exists()) { throw new EntityStillInUseApiException('task', 'time_entry'); } $task->delete(); return response() ->json(null, 204); } } ================================================ FILE: app/Http/Controllers/Api/V1/TimeEntryController.php ================================================ prevent_overlapping_time_entries) { return; } $query = TimeEntry::query() ->where('organization_id', $organization->getKey()) ->where('user_id', $member->user_id) ->when($exclude !== null, function (Builder $q) use ($exclude): void { $q->where('id', '!=', $exclude->getKey()); }) ->where(function (Builder $q) use ($start, $end): void { $q->where(function (Builder $q2) use ($start): void { $q2->where('end', '>', $start) ->where('start', '<', $start); }); if ($end !== null) { $q->orWhere(function (Builder $q4) use ($end): void { $q4->where('start', '<', $end) ->where('end', '>', $end); }); // Check if the new entry completely surrounds an existing entry $q->orWhere(function (Builder $q6) use ($start, $end): void { $q6->where('start', '>=', $start) ->where('end', '<=', $end); }); } }); if ($query->exists()) { throw new OverlappingTimeEntryApiException; } } protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void { parent::checkPermission($organization, $permission); if ($timeEntry !== null && $timeEntry->organization_id !== $organization->getKey()) { throw new AuthorizationException('Time entry does not belong to organization'); } } /** * Get time entries in organization * * If you only need time entries for a specific user, you can filter by `user_id`. * Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter. * * @return TimeEntryCollection * * @throws AuthorizationException * * @operationId getTimeEntries */ public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:view:own'); } else { $this->checkPermission($organization, 'time-entries:view:all'); } $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures); $totalCount = $timeEntriesQuery->count(); $limit = $request->getLimit(); if ($limit > 1000) { $limit = 1000; } $timeEntriesQuery->limit($limit); $timeEntriesQuery->skip($request->getOffset()); $timeEntries = $timeEntriesQuery->get(); if ($timeEntries->count() === $limit && $request->getOnlyFullDates()) { $user = $this->user(); $timezone = app(TimezoneService::class)->getTimezoneFromUser($user); $lastDate = null; /** @var TimeEntry $timeEntry */ foreach ($timeEntries as $timeEntry) { if ($lastDate === null || abs($lastDate->diffInDays($timeEntry->start->toImmutable()->timezone($timezone)->startOfDay())) > 0) { $lastDate = $timeEntry->start->toImmutable()->timezone($timezone)->startOfDay(); } } $timeEntries = $timeEntries->filter(function (TimeEntry $timeEntry) use ($lastDate, $timezone): bool { return $timeEntry->start->toImmutable()->timezone($timezone)->toDateString() !== $lastDate->toDateString(); }); if ($timeEntries->count() === 0) { Log::warning('User has has more than '.$limit.' time entries on one date', [ 'date' => $lastDate->toDateString(), 'user_id' => $request->input('user_id'), 'auth_user_id' => Auth::id(), 'limit' => $limit, ]); $timeEntries = $timeEntriesQuery ->limit(5000) ->where('start', '>=', $lastDate->copy()->startOfDay()->utc()) ->where('start', '<=', $lastDate->copy()->endOfDay()->utc()) ->get(); } } return (new TimeEntryCollection($timeEntries)) ->additional([ 'meta' => [ 'total' => $totalCount, ], ]); } /** * @return Builder */ private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder { $select = TimeEntry::SELECT_COLUMNS; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; if ($roundingType !== null && $roundingMinutes !== null) { $select = array_diff($select, ['start', 'end']); $select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start'); $select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end'); } $timeEntriesQuery = TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->select($select) ->orderBy('start', 'desc'); $filter = new TimeEntryFilter($timeEntriesQuery); $filter->addStartFilter($request->input('start')); $filter->addEndFilter($request->input('end')); $filter->addActiveFilter($request->input('active')); $filter->addMemberIdFilter($member); $filter->addMemberIdsFilter($request->input('member_ids')); $filter->addProjectIdsFilter($request->input('project_ids')); $filter->addTagIdsFilter($request->input('tag_ids')); $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); return $filter->get(); } /** * Export time entries in organization * * @throws AuthorizationException|PdfRendererIsNotConfiguredException|FeatureIsNotAvailableInFreePlanApiException * * @operationId exportTimeEntries */ public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:view:own'); } else { $this->checkPermission($organization, 'time-entries:view:all'); } $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization); $debug = $request->getDebug(); $format = $request->getFormatValue(); if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) { throw new FeatureIsNotAvailableInFreePlanApiException; } $user = $this->user(); $timezone = $user->timezone; $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures); $timeEntriesQuery->with([ 'task', 'client', 'project', 'user', 'tagsRelation', ]); $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $folderPath = 'exports'; $path = $folderPath.'/'.$filename; $localizationService = LocalizationService::forOrganization($organization); if ($format === ExportFormat::CSV) { $export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone); $export->export(); } elseif ($format === ExportFormat::PDF) { if (config('services.gotenberg.url') === null && ! $debug) { throw new PdfRendererIsNotConfiguredException; } $viewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf.blade.php')); if ($viewFile === false) { throw new \LogicException('View file not found'); } $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( $timeEntriesAggregateQuery, null, null, $user->timezone, $user->week_start, false, null, null, $showBillableRate, $roundingType, $roundingMinutes, ); $html = Blade::render($viewFile, [ 'timeEntries' => $timeEntriesQuery->get(), 'aggregatedData' => $aggregatedData, 'timezone' => $timezone, 'currency' => $organization->currency, 'start' => $request->getStart()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone), 'localization' => $localizationService, 'showBillableRate' => $showBillableRate, ]); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php')); if ($footerViewFile === false) { throw new \LogicException('View file not found'); } $footerHtml = Blade::render($footerViewFile); if ($debug) { return response()->json([ 'html' => $html, 'footer_html' => $footerHtml, ]); } $client = new Client([ 'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [ config('services.gotenberg.basic_auth_username'), config('services.gotenberg.basic_auth_password'), ] : null, ]); $request = Gotenberg::chromium(config('services.gotenberg.url')) ->pdf() ->assets( Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'), ) ->margins(0.39, 0.78, 0.39, 0.39) ->paperSize('8.27', '11.7') // A4 ->footer(Stream::string('footer', $footerHtml)) ->html(Stream::string('body', $html)); $tempFolder = TemporaryDirectory::make(); $filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client); Storage::disk(config('filesystems.private')) ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); } else { Excel::store( new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone, $localizationService), $path, config('filesystems.private'), $format->getExportPackageType(), [ 'visibility' => 'private', ] ); } return response()->json([ 'download_url' => Storage::disk(config('filesystems.private')) ->temporaryUrl($path, now()->addMinutes(5)), ]); } /** * Get aggregated time entries in organization * * This endpoint allows you to filter time entries and aggregate them by different criteria. * The parameters `group` and `sub_group` allow you to group the time entries by different criteria. * If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries. * * @operationId getAggregatedTimeEntries * * @return array{ * data: array{ * grouped_type: string|null, * grouped_data: null|array * }>, * seconds: int, * cost: int|null * } * } * * @throws AuthorizationException */ public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:view:own'); } else { $this->checkPermission($organization, 'time-entries:view:all'); } $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization); $user = $this->user(); $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $group1Type = $request->getGroup(); $group2Type = $request->getSubGroup(); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( $timeEntriesAggregateQuery, $group1Type, $group2Type, $user->timezone, $user->week_start, $request->getFillGapsInTimeGroups(), $request->getStart(), $request->getEnd(), $showBillableRate, $roundingType, $roundingMinutes ); return [ 'data' => $aggregatedData, ]; } /** * Export aggregated time entries in organization * * @operationId exportAggregatedTimeEntries * * @throws AuthorizationException * @throws PdfRendererIsNotConfiguredException * @throws GotenbergApiErrored * @throws NoOutputFileInResponse * @throws FeatureIsNotAvailableInFreePlanApiException */ public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:view:own'); } else { $this->checkPermission($organization, 'time-entries:view:all'); } $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization); $format = $request->getFormatValue(); if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) { throw new FeatureIsNotAvailableInFreePlanApiException; } $debug = $request->getDebug(); $user = $this->user(); $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $group = $request->getGroup(); $subGroup = $request->getSubGroup(); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $timeEntriesAggregateQuery->clone(), $group, $subGroup, $user->timezone, $user->week_start, false, $request->getStart(), $request->getEnd(), $showBillableRate, $roundingType, $roundingMinutes ); $dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries( $timeEntriesAggregateQuery->clone(), $request->getHistoryGroup(), null, $user->timezone, $user->week_start, true, $request->getStart(), $request->getEnd(), $showBillableRate, $roundingType, $roundingMinutes ); $currency = $organization->currency; $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $localizationService = LocalizationService::forOrganization($organization); $filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $folderPath = 'exports'; $path = $folderPath.'/'.$filename; if ($format === ExportFormat::PDF) { if (config('services.gotenberg.url') === null && ! $debug) { throw new PdfRendererIsNotConfiguredException; } $client = new Client([ 'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [ config('services.gotenberg.basic_auth_username'), config('services.gotenberg.basic_auth_password'), ] : null, ]); $viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf.blade.php')); if ($viewFile === false) { throw new \LogicException('View file not found'); } $html = Blade::render($viewFile, [ 'aggregatedData' => $aggregatedData, 'dataHistoryChart' => $dataHistoryChart, 'currency' => $currency, 'group' => $group, 'subGroup' => $subGroup, 'timezone' => $timezone, 'start' => $request->getStart()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone), 'debug' => $debug, 'localization' => $localizationService, 'showBillableRate' => $showBillableRate, ]); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php')); if ($footerViewFile === false) { throw new \LogicException('View file not found'); } $footerHtml = Blade::render($footerViewFile); if ($debug) { return response()->json([ 'html' => $html, 'footer_html' => $footerHtml, ]); } $request = Gotenberg::chromium(config('services.gotenberg.url')) ->pdf() ->waitForExpression("window.status === 'ready'") ->margins(0.39, 0.78, 0.39, 0.39) ->paperSize('8.27', '11.7') // A4 ->footer(Stream::string('footer', $footerHtml)) ->assets(Stream::path(resource_path('pdf/echarts.min.js'), 'echarts.min.js'), Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'), ) ->html(Stream::string('body', $html)); $tempFolder = TemporaryDirectory::make(); $filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client); Storage::disk(config('filesystems.private')) ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); } else { Excel::store( new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate), $path, config('filesystems.private'), $format->getExportPackageType(), [ 'visibility' => 'private', ] ); } return response()->json([ 'download_url' => Storage::disk(config('filesystems.private')) ->temporaryUrl($path, now()->addMinutes(5)), ]); } /** * @return Builder */ private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder { $timeEntriesQuery = TimeEntry::query() ->whereBelongsTo($organization, 'organization'); $filter = new TimeEntryFilter($timeEntriesQuery); $filter->addEndFilter($request->input('end')); $filter->addStartFilter($request->input('start')); $filter->addActiveFilter($request->input('active')); $filter->addMemberIdFilter($member); $filter->addMemberIdsFilter($request->input('member_ids')); $filter->addProjectIdsFilter($request->input('project_ids')); $filter->addTagIdsFilter($request->input('tag_ids')); $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); return $filter->get(); } /** * Create time entry * * @throws AuthorizationException * @throws TimeEntryStillRunningApiException * * @operationId createTimeEntry */ public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource { /** @var Member $member */ $member = Member::query()->findOrFail($request->input('member_id')); if ($member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:create:own'); } else { $this->checkPermission($organization, 'time-entries:create:all'); } if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) { throw new TimeEntryStillRunningApiException; } // Overlap check for create $start = Carbon::parse($request->input('start')); $end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null; $this->assertNoOverlap($organization, $member, $start, $end); $project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null; $client = $project?->client; $task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null; $timeEntry = new TimeEntry; $timeEntry->fill($request->validated()); $timeEntry->client()->associate($client); $timeEntry->user_id = $member->user_id; $timeEntry->description = $request->input('description') ?? ''; $timeEntry->organization()->associate($organization); $timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->save(); if ($project !== null) { RecalculateSpentTimeForProject::dispatch($project); } if ($task !== null) { RecalculateSpentTimeForTask::dispatch($task); } return new TimeEntryResource($timeEntry); } /** * Update time entry * * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException * * @operationId updateTimeEntry */ public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) { $this->checkPermission($organization, 'time-entries:update:own'); } else { $this->checkPermission($organization, 'time-entries:update:all'); } if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) { throw new TimeEntryCanNotBeRestartedApiException; } // Overlap check for update (exclude current) /** @var Member $effectiveMember */ $effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member; $effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start; $effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end; $this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry); $oldProject = $timeEntry->project; $oldTask = $timeEntry->task; $project = null; if ($request->has('project_id')) { $project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null; $client = $project?->client; $timeEntry->client()->associate($client); } $task = null; if ($request->has('task_id')) { $task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null; } $timeEntry->fill($request->validated()); $timeEntry->description = $request->input('description', $timeEntry->description) ?? ''; $timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->save(); if ($oldProject !== null) { RecalculateSpentTimeForProject::dispatch($oldProject); } if ($oldTask !== null) { RecalculateSpentTimeForTask::dispatch($oldTask); } if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) { RecalculateSpentTimeForProject::dispatch($project); } if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) { RecalculateSpentTimeForTask::dispatch($task); } return new TimeEntryResource($timeEntry); } /** * Update multiple time entries * * @operationId updateMultipleTimeEntries * * @throws AuthorizationException */ public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse { $this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']); $canAccessAll = $this->hasPermission($organization, 'time-entries:update:all'); $ids = $request->validated('ids'); $timeEntries = TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->with([ 'project', 'task', ]) ->whereIn('id', $ids) ->get(); $changes = $request->validated('changes'); if ($request->has('changes.description')) { $changes['description'] = $request->input('changes.description') ?? ''; } if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) { throw new AuthorizationException; } $project = null; $client = null; $overwriteClient = false; if ($request->has('changes.project_id')) { $project = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id')) : null; $client = $project?->client; $overwriteClient = true; } $task = null; if ($request->has('changes.task_id')) { $task = $request->input('changes.task_id') !== null ? Task::findOrFail((string) $request->input('changes.task_id')) : null; } $success = new Collection; $error = new Collection; foreach ($ids as $id) { /** @var TimeEntry|null $timeEntry */ $timeEntry = $timeEntries->firstWhere('id', $id); if ($timeEntry === null) { // Note: ID wrong or time entry in different organization $error->push($id); continue; } if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) { $error->push($id); continue; } $oldProject = $timeEntry->project; $oldTask = $timeEntry->task; $timeEntry->fill($changes); // If project is changed, but task is not, we remove the old task from the time entry if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) { $timeEntry->task()->disassociate(); } if ($overwriteClient) { $timeEntry->client()->associate($client); } $timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->save(); if ($oldTask !== null) { RecalculateSpentTimeForTask::dispatch($oldTask); } if ($oldProject !== null) { RecalculateSpentTimeForProject::dispatch($oldProject); } if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) { RecalculateSpentTimeForProject::dispatch($project); } if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) { RecalculateSpentTimeForTask::dispatch($task); } $success->push($id); } return response()->json([ 'success' => $success->toArray(), 'error' => $error->toArray(), ]); } /** * Delete time entry * * @throws AuthorizationException * * @operationId deleteTimeEntry */ public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse { if ($timeEntry->member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:delete:own', $timeEntry); } else { $this->checkPermission($organization, 'time-entries:delete:all', $timeEntry); } $project = $timeEntry->project; $task = $timeEntry->task; $timeEntry->delete(); if ($project !== null) { RecalculateSpentTimeForProject::dispatch($project); } if ($task !== null) { RecalculateSpentTimeForTask::dispatch($task); } return response() ->json(null, 204); } /** * Delete multiple time entries * * @throws AuthorizationException * * @operationId deleteTimeEntries */ public function destroyMultiple(Organization $organization, TimeEntryDestroyMultipleRequest $request): JsonResponse { $this->checkAnyPermission($organization, ['time-entries:delete:all', 'time-entries:delete:own']); $canDeleteAll = $this->hasPermission($organization, 'time-entries:delete:all'); $ids = $request->validated('ids'); $timeEntries = TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->with([ 'project', 'task', ]) ->whereIn('id', $ids) ->get(); $success = new Collection; $error = new Collection; foreach ($ids as $id) { /** @var TimeEntry|null $timeEntry */ $timeEntry = $timeEntries->firstWhere('id', $id); if ($timeEntry === null) { // Note: ID wrong or time entry in different organization $error->push($id); continue; } if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) { $error->push($id); continue; } $project = $timeEntry->project; $task = $timeEntry->task; $timeEntry->delete(); if ($project !== null) { RecalculateSpentTimeForProject::dispatch($project); } if ($task !== null) { RecalculateSpentTimeForTask::dispatch($task); } $success->push($id); } return response()->json([ 'success' => $success->toArray(), 'error' => $error->toArray(), ]); } } ================================================ FILE: app/Http/Controllers/Api/V1/UserController.php ================================================ user(); return new UserResource($user); } } ================================================ FILE: app/Http/Controllers/Api/V1/UserMembershipController.php ================================================ user(); $members = Member::query() ->whereBelongsTo($user, 'user') ->with(['organization']) ->get(); return new PersonalMembershipCollection($members); } } ================================================ FILE: app/Http/Controllers/Api/V1/UserTimeEntryController.php ================================================ user(); $activeTimeEntriesOfUser = TimeEntry::query() ->whereBelongsTo($user, 'user') ->whereNull('end') ->orderBy('start', 'desc') ->get(); if ($activeTimeEntriesOfUser->count() > 1) { Log::warning('User has more than one active time entry.', [ 'user' => $user->getKey(), ]); } $activeTimeEntry = $activeTimeEntriesOfUser->first(); if ($activeTimeEntry !== null) { return new TimeEntryResource($activeTimeEntry); } else { throw new ModelNotFoundException('No active time entry'); } } } ================================================ FILE: app/Http/Controllers/Controller.php ================================================ user(); /** @var Member|null $member */ $member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first(); if ($member === null) { Log::error('This function should only be called in authenticated context after checking the user is a member of the organization', [ 'user' => $user->getKey(), 'organization' => $organization->getKey(), ]); throw new AuthorizationException; } return $member; } /** * @throws AuthorizationException */ protected function currentOrganization(): Organization { $user = $this->user(); $organization = $user->currentTeam; if ($organization === null) { $organization = $user->organizations()->first(); } return $organization; } } ================================================ FILE: app/Http/Controllers/Web/Controller.php ================================================ user(); $organization = $this->currentOrganization(); $latestTeamActivity = null; if ($permissionStore->has($organization, 'time-entries:view:all')) { $latestTeamActivity = $dashboardService->latestTeamActivity($organization); } $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; return Inertia::render('Dashboard'); } } ================================================ FILE: app/Http/Controllers/Web/HealthCheckController.php ================================================ json([ 'success' => true, ]); } /** * Debug information for the application * This check checks the database and cache connectivity */ public function debug(Request $request): JsonResponse { // Check database connectivity User::query()->count(); // Check cache connectivity Cache::put('health-check', Carbon::now()->timestamp); // Check ip address correct behind load balancer $ipAddress = $request->ip(); $hostname = $request->getHost(); $secure = $request->secure(); $isTrustedProxy = $request->isFromTrustedProxy(); $dbTimezone = DB::select('show timezone;'); $response = [ 'ip_address' => $ipAddress, 'url' => $request->url(), 'path' => $request->path(), 'hostname' => $hostname, 'timestamp' => Carbon::now()->timestamp, 'date_time_utc' => Carbon::now('UTC')->toDateTimeString(), 'date_time_app' => Carbon::now()->toDateTimeString(), 'timezone' => $dbTimezone[0]->TimeZone, 'secure' => $secure, 'is_trusted_proxy' => $isTrustedProxy, ]; if (app()->hasDebugModeEnabled()) { $response['app_debug'] = true; $response['app_url'] = config('app.url'); $response['app_env'] = app()->environment(); $response['app_timezone'] = config('app.timezone'); $response['app_force_https'] = config('app.force_https'); $response['session_secure'] = config('session.secure'); $response['trusted_proxies'] = config('trustedproxy.proxies'); $headers = $request->headers->all(); if (isset($headers['cookie'])) { $headers['cookie'] = '***'; } $response['headers'] = $headers; } return response() ->json($response); } } ================================================ FILE: app/Http/Controllers/Web/HomeController.php ================================================ route('dashboard'); } else { return redirect('login'); } } } ================================================ FILE: app/Http/Kernel.php ================================================ */ protected $middleware = [ \App\Http\Middleware\ForceHttps::class, \App\Http\Middleware\TrustProxies::class, \Illuminate\Http\Middleware\HandleCors::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \App\Http\Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, ]; /** * The application's route middleware groups. * * @var array> */ protected $middlewareGroups = [ 'web' => [ \App\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\ShareInertiaData::class, \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class, ], 'api' => [ \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', \Illuminate\Routing\Middleware\SubstituteBindings::class, ForceJsonResponse::class, ], 'health-check' => [ ], ]; /** * The application's middleware aliases. * * Aliases may be used instead of class names to conveniently assign middleware to routes and groups. * * @var array */ protected $middlewareAliases = [ 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, 'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, 'signed' => \App\Http\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class, 'check-organization-blocked' => CheckOrganizationBlocked::class, ]; } ================================================ FILE: app/Http/Middleware/Authenticate.php ================================================ expectsJson() ? null : route('login'); } } ================================================ FILE: app/Http/Middleware/CheckOrganizationBlocked.php ================================================ route('organization'); if (! ($organization instanceof Organization)) { throw new \LogicException('The organization must be loaded before this middleware.'); } /** @var BillingContract $billing */ $billing = app(BillingContract::class); if ($billing->isBlocked($organization)) { throw new OrganizationHasNoSubscriptionButMultipleMembersException; } return $next($request); } } ================================================ FILE: app/Http/Middleware/EncryptCookies.php ================================================ */ protected $except = [ // ]; } ================================================ FILE: app/Http/Middleware/EnsureEmailIsVerified.php ================================================ isLocal()) { if ($request->user() === null || (! $request->user()->hasVerifiedEmail())) { return $request->expectsJson() ? abort(403, 'Your email address is not verified.') : Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice')); } } return $next($request); } } ================================================ FILE: app/Http/Middleware/ForceHttps.php ================================================ server->set('HTTPS', 'on'); $request->headers->set('X-Forwarded-Proto', 'https'); } return $next($request); } } ================================================ FILE: app/Http/Middleware/ForceJsonResponse.php ================================================ headers->set('Accept', 'application/json'); return $next($request); } } ================================================ FILE: app/Http/Middleware/HandleInertiaRequests.php ================================================ */ public function share(Request $request): array { $hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing'); $hasServices = Module::has('Services') && Module::isEnabled('Services'); /** @var BillingContract $billing */ $billing = app(BillingContract::class); $currentOrganization = $request->user()?->currentTeam; return array_merge(parent::share($request), [ 'has_billing_extension' => $hasBilling, 'has_invoicing_extension' => $hasInvoicing, 'has_services_extension' => $hasServices, 'billing' => $currentOrganization !== null ? [ 'has_subscription' => $billing->hasSubscription($currentOrganization), 'has_trial' => $billing->hasTrial($currentOrganization), 'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(), 'is_blocked' => $billing->isBlocked($currentOrganization), ] : null, 'flash' => [ 'message' => fn () => $request->session()->get('message'), ], ]); } } ================================================ FILE: app/Http/Middleware/PreventRequestsDuringMaintenance.php ================================================ */ protected $except = [ // ]; } ================================================ FILE: app/Http/Middleware/RedirectIfAuthenticated.php ================================================ check()) { return redirect(RouteServiceProvider::HOME); } } return $next($request); } } ================================================ FILE: app/Http/Middleware/ShareInertiaData.php ================================================ function () use ($request) { /** @var User|null $user */ $user = $request->user(); return [ 'canCreateTeams' => $user !== null && Jetstream::userHasTeamFeatures($user) && Gate::forUser($user)->check('create', Jetstream::newTeamModel()), 'canManageTwoFactorAuthentication' => Features::canManageTwoFactorAuthentication(), 'canUpdatePassword' => Features::enabled(Features::updatePasswords()), 'canUpdateProfileInformation' => Features::canUpdateProfileInformation(), 'hasEmailVerification' => Features::enabled(Features::emailVerification()), 'flash' => $request->session()->get('flash', []), 'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(), 'hasApiFeatures' => Jetstream::hasApiFeatures(), 'hasTeamFeatures' => Jetstream::hasTeamFeatures(), 'hasTermsAndPrivacyPolicyFeature' => Jetstream::hasTermsAndPrivacyPolicyFeature(), 'managesProfilePhotos' => Jetstream::managesProfilePhotos(), ]; }, 'auth' => [ 'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [], 'user' => function () use ($request): array { /** @var User|null $user */ $user = $request->user(); if ($user === null) { return []; } return array_merge([ 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, 'email_verified_at' => $user->email_verified_at, 'current_team_id' => $user->current_team_id, 'profile_photo_path' => $user->profile_photo_path, 'timezone' => $user->timezone, 'week_start' => $user->week_start, 'profile_photo_url' => $user->profile_photo_url, 'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication()) && ! is_null($user->two_factor_secret), 'current_team' => $user->currentTeam !== null ? [ 'id' => $user->currentTeam->id, 'user_id' => $user->currentTeam->user_id, 'name' => $user->currentTeam->name, 'personal_team' => $user->currentTeam->personal_team, 'currency' => $user->currentTeam->currency, ] : null, ], array_filter([ 'all_teams' => $user->organizations->map(function (Organization $organization): array { return [ 'id' => $organization->id, 'name' => $organization->name, 'personal_team' => $organization->personal_team, 'currency' => $organization->currency, 'membership' => [ 'role' => $organization->membership->role, 'id' => $organization->membership->id, ], ]; })->all(), ])); }, ], 'errorBags' => function () { /** @var array|null $bags */ $bags = Session::get('errors')?->getBags(); $bagsCollection = collect($bags ?: []); return $bagsCollection->mapWithKeys(function (MessageBag $bag, string $key) { return [$key => $bag->messages()]; })->all(); }, ]); return $next($request); } } ================================================ FILE: app/Http/Middleware/TrimStrings.php ================================================ */ protected $except = [ 'current_password', 'password', 'password_confirmation', ]; } ================================================ FILE: app/Http/Middleware/TrustProxies.php ================================================ |string|null */ protected $proxies; /** * The headers that should be used to detect proxies. * * @var int */ protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB | Request::HEADER_X_FORWARDED_TRAEFIK; } ================================================ FILE: app/Http/Middleware/ValidateSignature.php ================================================ */ protected array $except = [ // 'fbclid', // 'utm_campaign', // 'utm_content', // 'utm_medium', // 'utm_source', // 'utm_term', ]; } ================================================ FILE: app/Http/Middleware/VerifyCsrfToken.php ================================================ */ protected $except = [ // ]; } ================================================ FILE: app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php ================================================ > */ public function rules(): array { return [ 'name' => [ 'required', 'string', 'min:1', 'max:255', ], ]; } public function getName(): string { return $this->input('name'); } } ================================================ FILE: app/Http/Requests/V1/BaseFormRequest.php ================================================ */ protected function moneyRules(bool $bigInt = false): array { $rules = [ 'integer', 'min:0', ]; if ($bigInt) { $rules[] = 'max:9223372036854775807'; } else { $rules[] = 'max:2147483647'; } return $rules; } } ================================================ FILE: app/Http/Requests/V1/Client/ClientIndexRequest.php ================================================ > */ public function rules(): array { return [ 'page' => [ 'integer', 'min:1', 'max:2147483647', ], 'archived' => [ 'string', 'in:true,false,all', ], ]; } public function getFilterArchived(): string { return $this->input('archived', 'false'); } } ================================================ FILE: app/Http/Requests/V1/Client/ClientStoreRequest.php ================================================ > */ public function rules(): array { return [ 'name' => [ 'required', 'string', 'min:1', 'max:255', UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->withCustomTranslation('validation.client_name_already_exists'), ], ]; } } ================================================ FILE: app/Http/Requests/V1/Client/ClientUpdateRequest.php ================================================ > */ public function rules(): array { return [ // Name of the client 'name' => [ 'required', 'string', 'min:1', 'max:255', UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'), ], 'is_archived' => [ 'boolean', ], ]; } public function getIsArchived(): bool { assert($this->has('is_archived')); return (bool) $this->input('is_archived'); } } ================================================ FILE: app/Http/Requests/V1/Import/ImportRequest.php ================================================ > */ public function rules(): array { return [ 'type' => [ 'required', 'string', ], 'data' => [ 'required', 'string', ], ]; } } ================================================ FILE: app/Http/Requests/V1/Invitation/InvitationIndexRequest.php ================================================ > */ public function rules(): array { return [ 'page' => [ 'integer', 'min:1', 'max:2147483647', ], ]; } } ================================================ FILE: app/Http/Requests/V1/Invitation/InvitationStoreRequest.php ================================================ > */ public function rules(): array { return [ 'email' => [ 'required', 'email', ], 'role' => [ 'required', 'string', Rule::enum(Role::class) ->except([Role::Owner, Role::Placeholder]), ], ]; } public function getRole(): Role { return Role::from($this->input('role')); } public function getEmail(): string { return $this->input('email'); } } ================================================ FILE: app/Http/Requests/V1/Member/MemberDestroyRequest.php ================================================ > */ public function rules(): array { return [ 'delete_related' => [ 'string', 'in:true,false', ], ]; } public function getDeleteRelated(): bool { return $this->input('delete_related', 'false') === 'true'; } } ================================================ FILE: app/Http/Requests/V1/Member/MemberIndexRequest.php ================================================ > */ public function rules(): array { return [ 'page' => [ 'integer', 'min:1', 'max:2147483647', ], ]; } } ================================================ FILE: app/Http/Requests/V1/Member/MemberMergeIntoRequest.php ================================================ > */ public function rules(): array { return [ // ID of the member to which the data should be transferred (destination) 'member_id' => [ 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], ]; } public function getMemberId(): string { return (string) $this->input('member_id'); } } ================================================ FILE: app/Http/Requests/V1/Member/MemberUpdateRequest.php ================================================ > */ public function rules(): array { return [ 'role' => [ 'string', Rule::enum(Role::class), ], 'billable_rate' => array_merge( [ 'nullable', ], $this->moneyRules() ), ]; } public function getBillableRate(): ?int { $input = $this->input('billable_rate'); return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } public function getRole(): Role { return Role::from($this->input('role')); } } ================================================ FILE: app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php ================================================ > */ public function rules(): array { return [ 'name' => [ 'string', 'max:255', ], 'billable_rate' => array_merge( [ 'nullable', ], $this->moneyRules() ), 'employees_can_see_billable_rates' => [ 'boolean', ], 'employees_can_manage_tasks' => [ 'boolean', ], 'prevent_overlapping_time_entries' => [ 'boolean', ], 'number_format' => [ Rule::enum(NumberFormat::class), ], 'currency_format' => [ Rule::enum(CurrencyFormat::class), ], 'date_format' => [ Rule::enum(DateFormat::class), ], 'interval_format' => [ Rule::enum(IntervalFormat::class), ], 'time_format' => [ Rule::enum(TimeFormat::class), ], ]; } public function getName(): ?string { return $this->has('name') ? (string) $this->input('name') : null; } public function getNumberFormat(): ?NumberFormat { return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null; } public function getCurrencyFormat(): ?CurrencyFormat { return $this->has('currency_format') ? CurrencyFormat::from($this->input('currency_format')) : null; } public function getDateFormat(): ?DateFormat { return $this->has('date_format') ? DateFormat::from($this->input('date_format')) : null; } public function getIntervalFormat(): ?IntervalFormat { return $this->has('interval_format') ? IntervalFormat::from($this->input('interval_format')) : null; } public function getTimeFormat(): ?TimeFormat { return $this->has('time_format') ? TimeFormat::from($this->input('time_format')) : null; } public function getBillableRate(): ?int { $input = $this->input('billable_rate'); return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } public function getEmployeesCanSeeBillableRates(): ?bool { return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null; } public function getEmployeesCanManageTasks(): ?bool { return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null; } public function getPreventOverlappingTimeEntries(): ?bool { return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null; } } ================================================ FILE: app/Http/Requests/V1/Project/ProjectIndexRequest.php ================================================ > */ public function rules(): array { return [ 'page' => [ 'integer', 'min:1', 'max:2147483647', ], 'archived' => [ 'string', 'in:true,false,all', ], ]; } public function getFilterArchived(): string { return $this->input('archived', 'false'); } } ================================================ FILE: app/Http/Requests/V1/Project/ProjectStoreRequest.php ================================================ > */ public function rules(): array { return [ // Name of the project, the name needs to be unique per client and organization 'name' => [ 'required', 'string', 'min:1', 'max:255', UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder { /** @var Builder $builder */ $clientId = $this->input('client_id'); if (! is_string($clientId) || ! Str::isUuid($clientId)) { $clientId = null; } return $builder->whereBelongsTo($this->organization, 'organization') ->where('client_id', $clientId); })->withCustomTranslation('validation.project_name_already_exists'), ], 'color' => [ 'required', 'string', 'max:255', new ColorRule, ], 'is_billable' => [ 'required', 'boolean', ], 'billable_rate' => array_merge( [ 'nullable', ], $this->moneyRules() ), // ID of the client 'client_id' => [ 'present', 'nullable', ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], // Estimated time in seconds 'estimated_time' => [ 'nullable', 'integer', 'min:0', 'max:2147483647', ], // Whether the project is public 'is_public' => [ 'boolean', ], ]; } public function getIsPublic(): bool { return $this->has('is_public') && $this->boolean('is_public'); } public function getBillableRate(): ?int { $input = $this->input('billable_rate'); return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } public function getEstimatedTime(): ?int { $input = $this->input('estimated_time'); return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null; } } ================================================ FILE: app/Http/Requests/V1/Project/ProjectUpdateRequest.php ================================================ > */ public function rules(): array { return [ 'name' => [ 'required', 'string', 'max:255', UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder { /** @var Builder $builder */ $clientId = $this->input('client_id'); if (! is_string($clientId) || ! Str::isUuid($clientId)) { $clientId = null; } return $builder->whereBelongsTo($this->organization, 'organization') ->where('client_id', $clientId); })->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'), ], 'color' => [ 'required', 'string', 'max:255', new ColorRule, ], 'is_billable' => [ 'required', 'boolean', ], 'is_archived' => [ 'boolean', ], 'is_public' => [ 'boolean', ], 'client_id' => [ 'present', 'nullable', ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], 'billable_rate' => array_merge([ 'nullable', ], $this->moneyRules() ), // Estimated time in seconds 'estimated_time' => [ 'nullable', 'integer', 'min:0', 'max:2147483647', ], ]; } public function getIsArchived(): bool { assert($this->has('is_archived')); return (bool) $this->input('is_archived'); } public function getBillableRate(): ?int { $input = $this->input('billable_rate'); return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } public function getEstimatedTime(): ?int { $input = $this->input('estimated_time'); return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null; } } ================================================ FILE: app/Http/Requests/V1/ProjectMember/ProjectMemberIndexRequest.php ================================================ > */ public function rules(): array { return [ 'page' => [ 'integer', 'min:1', 'max:2147483647', ], ]; } } ================================================ FILE: app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php ================================================ > */ public function rules(): array { return [ 'member_id' => [ 'required', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], 'billable_rate' => array_merge( [ 'nullable', ], $this->moneyRules() ), ]; } public function getBillableRate(): ?int { $input = $this->input('billable_rate'); return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } } ================================================ FILE: app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php ================================================ > */ public function rules(): array { return [ 'billable_rate' => array_merge( [ 'nullable', ], $this->moneyRules() ), ]; } public function getBillableRate(): ?int { $input = $this->input('billable_rate'); return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } } ================================================ FILE: app/Http/Requests/V1/Report/ReportIndexRequest.php ================================================ > */ public function rules(): array { return [ 'page' => [ 'integer', 'min:1', 'max:2147483647', ], ]; } } ================================================ FILE: app/Http/Requests/V1/Report/ReportStoreRequest.php ================================================ > */ public function rules(): array { return [ 'name' => [ 'required', 'string', 'max:255', ], 'description' => [ 'nullable', 'string', ], 'is_public' => [ 'required', 'boolean', ], // After this date the report will be automatically set to private (is_public=false) (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") 'public_until' => [ 'nullable', 'date_format:Y-m-d\TH:i:s\Z', 'after:now', ], 'properties' => [ 'required', 'array', ], 'properties.start' => [ 'required', 'date_format:Y-m-d\TH:i:s\Z', ], 'properties.end' => [ 'required', 'date_format:Y-m-d\TH:i:s\Z', ], 'properties.active' => [ 'nullable', 'boolean', ], 'properties.member_ids' => [ 'nullable', 'array', ], 'properties.member_ids.*' => [ 'string', 'uuid', ], 'properties.billable' => [ 'nullable', 'boolean', ], 'properties.client_ids' => [ 'nullable', 'array', ], 'properties.client_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } if (! Str::isUuid($value)) { $fail('The '.$attribute.' must be a valid UUID.'); } }, ], // Filter by project IDs, project IDs are OR combined 'properties.project_ids' => [ 'nullable', 'array', ], 'properties.project_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } if (! Str::isUuid($value)) { $fail('The '.$attribute.' must be a valid UUID.'); } }, ], // Filter by tag IDs, tag IDs are OR combined 'properties.tag_ids' => [ 'nullable', 'array', ], 'properties.tag_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } if (! Str::isUuid($value)) { $fail('The '.$attribute.' must be a valid UUID.'); } }, ], 'properties.task_ids' => [ 'nullable', 'array', ], 'properties.task_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } if (! Str::isUuid($value)) { $fail('The '.$attribute.' must be a valid UUID.'); } }, ], 'properties.group' => [ 'required', Rule::enum(TimeEntryAggregationType::class), ], 'properties.sub_group' => [ 'required', Rule::enum(TimeEntryAggregationType::class), ], 'properties.history_group' => [ 'required', Rule::enum(TimeEntryAggregationTypeInterval::class), ], 'properties.week_start' => [ 'nullable', Rule::enum(Weekday::class), ], 'properties.timezone' => [ 'nullable', 'timezone:all', ], // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null. 'properties.rounding_type' => [ 'nullable', 'string', Rule::enum(TimeEntryRoundingType::class), ], // Defines the length of the interval that the time entry rounding rounds to. 'properties.rounding_minutes' => [ 'nullable', 'numeric', 'integer', ], ]; } public function getName(): string { return (string) $this->input('name'); } public function getDescription(): ?string { return $this->input('description'); } public function getIsPublic(): bool { return (bool) $this->input('is_public'); } public function getPublicUntil(): ?Carbon { $publicUntil = $this->input('public_until'); return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil); } public function getPropertyStart(): Carbon { $start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.start')); if ($start === null) { throw new \LogicException('Start date validation is not working'); } return $start; } public function getPropertyEnd(): Carbon { $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.end')); if ($end === null) { throw new \LogicException('End date validation is not working'); } return $end; } public function getPropertyActive(): ?bool { if ($this->has('properties.active') && $this->input('properties.active') !== null) { return (bool) $this->input('properties.active'); } return null; } public function getPropertyBillable(): ?bool { if ($this->has('properties.billable') && $this->input('properties.billable') !== null) { return (bool) $this->input('properties.billable'); } return null; } public function getPropertyGroup(): TimeEntryAggregationType { return TimeEntryAggregationType::from($this->input('properties.group')); } public function getPropertySubGroup(): TimeEntryAggregationType { return TimeEntryAggregationType::from($this->input('properties.sub_group')); } public function getPropertyHistoryGroup(): TimeEntryAggregationTypeInterval { 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'); } } ================================================ FILE: app/Http/Requests/V1/Report/ReportUpdateRequest.php ================================================ > */ public function rules(): array { return [ 'name' => [ 'string', 'max:255', ], 'description' => [ 'nullable', 'string', ], 'is_public' => [ 'boolean', ], 'public_until' => [ 'nullable', 'date_format:Y-m-d\TH:i:s\Z', 'after:now', ], ]; } public function getName(): string { return (string) $this->input('name'); } public function getDescription(): ?string { return $this->input('description'); } public function getIsPublic(): bool { return (bool) $this->input('is_public'); } public function getPublicUntil(): ?Carbon { $publicUntil = $this->input('public_until'); return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil); } } ================================================ FILE: app/Http/Requests/V1/Tag/TagIndexRequest.php ================================================ > */ public function rules(): array { return [ 'page' => [ 'integer', 'min:1', 'max:2147483647', ], ]; } } ================================================ FILE: app/Http/Requests/V1/Tag/TagStoreRequest.php ================================================ > */ public function rules(): array { return [ 'name' => [ 'required', 'string', 'min:1', 'max:255', UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->withCustomTranslation('validation.tag_name_already_exists'), ], ]; } } ================================================ FILE: app/Http/Requests/V1/Tag/TagUpdateRequest.php ================================================ > */ public function rules(): array { return [ 'name' => [ 'required', 'string', 'min:1', 'max:255', UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'), ], ]; } } ================================================ FILE: app/Http/Requests/V1/Task/TaskIndexRequest.php ================================================ > */ public function rules(): array { return [ 'page' => [ 'integer', 'min:1', 'max:2147483647', ], 'project_id' => [ ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ $builder = $builder->whereBelongsTo($this->organization, 'organization'); if (! app(PermissionStore::class)->has($this->organization, 'tasks:view:all')) { $builder = $builder->visibleByEmployee(Auth::user()); } return $builder; })->uuid(), ], 'done' => [ 'string', 'in:true,false,all', ], ]; } public function getFilterDone(): string { return $this->input('done', 'false'); } } ================================================ FILE: app/Http/Requests/V1/Task/TaskStoreRequest.php ================================================ > */ public function rules(): array { return [ 'name' => [ 'required', 'string', 'min:1', 'max:255', UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->where('project_id', '=', $this->input('project_id')); })->withCustomTranslation('validation.task_name_already_exists'), ], 'project_id' => [ 'required', ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], // Estimated time in seconds 'estimated_time' => [ 'nullable', 'integer', 'min:0', 'max:2147483647', ], ]; } public function getEstimatedTime(): ?int { $input = $this->input('estimated_time'); return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null; } } ================================================ FILE: app/Http/Requests/V1/Task/TaskUpdateRequest.php ================================================ > */ public function rules(): array { return [ 'name' => [ 'required', 'string', 'min:1', 'max:255', UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->where('project_id', '=', $this->task->project_id); })->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'), ], 'is_done' => [ 'boolean', ], // Estimated time in seconds 'estimated_time' => [ 'nullable', 'integer', 'min:0', 'max:2147483647', ], ]; } public function getIsDone(): bool { assert($this->has('is_done')); return $this->boolean('is_done'); } public function getEstimatedTime(): ?int { $input = $this->input('estimated_time'); return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null; } } ================================================ FILE: app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php ================================================ > */ public function rules(): array { return [ // Data format of the export 'format' => [ 'required', 'string', Rule::enum(ExportFormat::class), ], // Type of first grouping 'group' => [ 'required', Rule::enum(TimeEntryAggregationType::class), ], // Type of second grouping 'sub_group' => [ 'required', Rule::enum(TimeEntryAggregationType::class), ], // Type of grouping of the historic aggregation (time chart) 'history_group' => [ 'required', 'nullable', Rule::enum(TimeEntryAggregationTypeInterval::class), ], // Filter by member ID 'member_id' => [ 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter 'member_ids' => [ 'array', 'min:1', ], 'member_ids.*' => [ 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], // Filter by user ID 'user_id' => [ 'string', ExistsEloquent::make(User::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->belongsToOrganization($this->organization); })->uuid(), ], // Filter by project IDs, project IDs are OR combined 'project_ids' => [ 'array', 'min:1', ], 'project_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by client IDs, client IDs are OR combined 'client_ids' => [ 'array', 'min:1', ], 'client_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ 'array', 'min:1', ], 'tag_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ 'array', 'min:1', ], 'task_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ 'required', 'string', 'date_format:Y-m-d\TH:i:s\Z', 'before:end', ], // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'end' => [ 'required', 'string', 'date_format:Y-m-d\TH:i:s\Z', ], // Filter by active status (active means has no end date, is still running) 'active' => [ 'string', 'in:true,false', ], // Filter by billable status 'billable' => [ 'string', 'in:true,false', ], 'fill_gaps_in_time_groups' => [ 'string', 'in:true,false', ], 'debug' => [ 'string', 'in:true,false', ], // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null. 'rounding_type' => [ 'nullable', 'string', Rule::enum(TimeEntryRoundingType::class), ], // Defines the length of the interval that the time entry rounding rounds to. 'rounding_minutes' => [ 'nullable', 'numeric', 'integer', ], ]; } public function getDebug(): bool { return $this->input('debug') === 'true'; } public function getGroup(): TimeEntryAggregationType { return TimeEntryAggregationType::from($this->input('group')); } public function getSubGroup(): TimeEntryAggregationType { return TimeEntryAggregationType::from($this->input('sub_group')); } public function getHistoryGroup(): TimeEntryAggregationType { return TimeEntryAggregationType::fromInterval(TimeEntryAggregationTypeInterval::from($this->input('history_group'))); } public function getStart(): Carbon { $start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC'); if ($start === null) { throw new \LogicException('Start date validation is not working'); } return $start; } public function getEnd(): Carbon { $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC'); if ($end === null) { throw new \LogicException('End date validation is not working'); } return $end; } public function getFormatValue(): ExportFormat { 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'); } } ================================================ FILE: app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php ================================================ > */ public function rules(): array { return [ // Type of first grouping 'group' => [ 'nullable', 'required_with:sub_group', Rule::enum(TimeEntryAggregationType::class), ], // Type of second grouping 'sub_group' => [ 'nullable', Rule::enum(TimeEntryAggregationType::class), ], // Filter by member ID 'member_id' => [ 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter 'member_ids' => [ 'array', 'min:1', ], 'member_ids.*' => [ 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], // Filter by user ID 'user_id' => [ 'string', ExistsEloquent::make(User::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->belongsToOrganization($this->organization); })->uuid(), ], // Filter by project IDs, project IDs are OR combined 'project_ids' => [ 'array', 'min:1', ], 'project_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by client IDs, client IDs are OR combined 'client_ids' => [ 'array', 'min:1', ], 'client_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ 'array', 'min:1', ], 'tag_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ 'array', 'min:1', ], 'task_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ 'nullable', 'string', 'date_format:Y-m-d\TH:i:s\Z', 'before:end', ], // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'end' => [ 'nullable', 'string', 'date_format:Y-m-d\TH:i:s\Z', ], // Filter by active status (active means has no end date, is still running) 'active' => [ 'string', 'in:true,false', ], // Filter by billable status 'billable' => [ 'string', 'in:true,false', ], 'fill_gaps_in_time_groups' => [ 'string', 'in:true,false', ], // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null. 'rounding_type' => [ 'nullable', 'string', Rule::enum(TimeEntryRoundingType::class), ], // Defines the length of the interval that the time entry rounding rounds to. 'rounding_minutes' => [ 'nullable', 'numeric', 'integer', ], ]; } public function getGroup(): ?TimeEntryAggregationType { return $this->input('group') !== null ? TimeEntryAggregationType::from($this->input('group')) : null; } public function getSubGroup(): ?TimeEntryAggregationType { return $this->input('sub_group') !== null ? TimeEntryAggregationType::from($this->input('sub_group')) : null; } public function getFillGapsInTimeGroups(): bool { return $this->has('fill_gaps_in_time_groups') && $this->input('fill_gaps_in_time_groups') === 'true'; } public function getStart(): ?Carbon { return $this->input('start') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC') : null; } public function getEnd(): ?Carbon { 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'); } } ================================================ FILE: app/Http/Requests/V1/TimeEntry/TimeEntryDestroyMultipleRequest.php ================================================ > */ public function rules(): array { return [ 'ids' => [ 'required', 'array', ], 'ids.*' => [ 'string', 'uuid', ], ]; } } ================================================ FILE: app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php ================================================ > */ public function rules(): array { return [ 'format' => [ 'required', 'string', Rule::enum(ExportFormat::class), ], // Filter by member ID 'member_id' => [ 'string', 'uuid', new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); }), ], // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter 'member_ids' => [ 'array', 'min:1', ], 'member_ids.*' => [ 'string', 'uuid', new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); }), ], // Filter by client IDs, client IDs are OR combined 'client_ids' => [ 'array', 'min:1', ], 'client_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by project IDs, project IDs are OR combined 'project_ids' => [ 'array', 'min:1', ], 'project_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ 'array', 'min:1', ], 'tag_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ 'array', 'min:1', ], 'task_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ 'required', 'string', 'date_format:Y-m-d\TH:i:s\Z', 'before:end', ], // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'end' => [ 'required', 'string', 'date_format:Y-m-d\TH:i:s\Z', ], // Filter by active status (active means has no end date, is still running) 'active' => [ 'string', 'in:true,false', ], // Filter by billable status 'billable' => [ 'string', 'in:true,false', ], // Limit the number of returned time entries (default: 150) 'limit' => [ 'integer', 'min:1', 'max:500', ], // Filter makes sure that only time entries of a whole date are returned 'only_full_dates' => [ 'string', 'in:true,false', ], 'debug' => [ 'string', 'in:true,false', ], // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null. 'rounding_type' => [ 'nullable', 'string', Rule::enum(TimeEntryRoundingType::class), ], // Defines the length of the interval that the time entry rounding rounds to. 'rounding_minutes' => [ 'nullable', 'numeric', 'integer', ], ]; } public function getDebug(): bool { return $this->input('debug', 'false') === 'true'; } public function getStart(): Carbon { $start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC'); if ($start === null) { throw new \LogicException('Start date validation is not working'); } return $start; } public function getEnd(): Carbon { $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC'); if ($end === null) { throw new \LogicException('End date validation is not working'); } return $end; } public function getOnlyFullDates(): bool { return $this->input('only_full_dates', 'false') === 'true'; } public function getFormatValue(): ExportFormat { 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'); } } ================================================ FILE: app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php ================================================ > */ public function rules(): array { return [ // Filter by member ID 'member_id' => [ 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter 'member_ids' => [ 'array', 'min:1', ], 'member_ids.*' => [ 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], // Filter by client IDs, client IDs are OR combined 'client_ids' => [ 'array', 'min:1', ], 'client_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by project IDs, project IDs are OR combined 'project_ids' => [ 'array', 'min:1', ], 'project_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ 'array', 'min:1', ], 'tag_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ 'array', 'min:1', ], 'task_ids.*' => [ 'string', function (string $attribute, mixed $value, \Closure $fail): void { if ($value === TimeEntryFilter::NONE_VALUE) { return; } ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid()->validate($attribute, $value, $fail); }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ 'nullable', 'string', 'date_format:Y-m-d\TH:i:s\Z', 'before:end', ], // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'end' => [ 'nullable', 'string', 'date_format:Y-m-d\TH:i:s\Z', ], // Filter by active status (active means has no end date, is still running) 'active' => [ 'string', 'in:true,false', ], // Filter by billable status 'billable' => [ 'string', 'in:true,false', ], // Limit the number of returned time entries (default: 150) 'limit' => [ 'integer', 'min:1', 'max:500', ], // Skip the first n time entries (default: 0) 'offset' => [ 'integer', 'min:0', 'max:2147483647', ], // Filter makes sure that only time entries of a whole date are returned 'only_full_dates' => [ 'string', 'in:true,false', ], // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null. 'rounding_type' => [ 'nullable', 'string', Rule::enum(TimeEntryRoundingType::class), ], // Defines the length of the interval that the time entry rounding rounds to. 'rounding_minutes' => [ 'nullable', 'numeric', 'integer', ], ]; } public function getOnlyFullDates(): bool { return $this->input('only_full_dates', 'false') === 'true'; } public function getLimit(): int { return $this->has('limit') ? (int) $this->validated('limit', 100) : 100; } public function getOffset(): int { 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'); } } ================================================ FILE: app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php ================================================ > */ public function rules(): array { return [ // ID of the organization member that the time entry should belong to 'member_id' => [ 'required', 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], 'project_id' => [ 'nullable', 'string', 'required_with:task_id', ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ $builder = $builder->whereBelongsTo($this->organization, 'organization'); // If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of $permissionStore = app(PermissionStore::class); if (! $permissionStore->has($this->organization, 'time-entries:create:all') && ! $permissionStore->has($this->organization, 'projects:view:all')) { $builder = $builder->visibleByEmployee(Auth::user()); } return $builder; })->uuid(), ], // ID of the task that the time entry should belong to 'task_id' => [ 'nullable', 'string', ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization') ->where('project_id', $this->input('project_id')); })->uuid()->withMessage(__('validation.task_belongs_to_project')), ], // Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") 'start' => [ 'required', 'date_format:Y-m-d\TH:i:s\Z', ], // End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") 'end' => [ 'nullable', 'date_format:Y-m-d\TH:i:s\Z', 'after_or_equal:start', ], // Whether time entry is billable 'billable' => [ 'required', 'boolean', ], // Description of time entry 'description' => [ 'nullable', 'string', 'max:5000', ], // List of tag IDs 'tags' => [ 'nullable', 'array', ], 'tags.*' => [ ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], ]; } } ================================================ FILE: app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php ================================================ > */ public function rules(): array { return [ 'ids' => [ 'required', 'array', ], 'ids.*' => [ 'string', 'uuid', ], 'changes' => [ 'required', 'array', ], // ID of the organization member that the time entry should belong to 'changes.member_id' => [ 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], // ID of the project that the time entry should belong to 'changes.project_id' => [ 'nullable', 'string', 'required_with:task_id', ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ $builder = $builder->whereBelongsTo($this->organization, 'organization'); // If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of $permissionStore = app(PermissionStore::class); if (! $permissionStore->has($this->organization, 'time-entries:update:all') && ! $permissionStore->has($this->organization, 'projects:view:all')) { $builder = $builder->visibleByEmployee(Auth::user()); } return $builder; })->uuid(), ], // ID of the task that the time entry should belong to 'changes.task_id' => [ 'nullable', 'string', ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization') ->where('project_id', $this->input('changes.project_id')); })->uuid()->withMessage(__('validation.task_belongs_to_project')), ], // Whether time entry is billable 'changes.billable' => [ 'boolean', ], // Description of time entry 'changes.description' => [ 'nullable', 'string', 'max:5000', ], // List of tag IDs 'changes.tags' => [ 'nullable', 'array', ], 'changes.tags.*' => [ 'string', ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], ]; } } ================================================ FILE: app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php ================================================ > */ public function rules(): array { return [ // ID of the organization member that the time entry should belong to 'member_id' => [ 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], // ID of the project that the time entry should belong to 'project_id' => [ 'nullable', 'string', 'required_with:task_id', ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ $builder = $builder->whereBelongsTo($this->organization, 'organization'); // If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of $permissionStore = app(PermissionStore::class); if (! $permissionStore->has($this->organization, 'time-entries:update:all') && ! $permissionStore->has($this->organization, 'projects:view:all')) { $builder = $builder->visibleByEmployee(Auth::user()); } return $builder; })->uuid(), ], // ID of the task that the time entry should belong to 'task_id' => [ 'nullable', 'string', ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization') ->where('project_id', $this->input('project_id')); })->uuid()->withMessage(__('validation.task_belongs_to_project')), ], // Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") 'start' => [ 'date_format:Y-m-d\TH:i:s\Z', ], // End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") 'end' => [ 'nullable', 'date_format:Y-m-d\TH:i:s\Z', 'after_or_equal:start', ], // Whether time entry is billable 'billable' => [ 'boolean', ], // Description of time entry 'description' => [ 'nullable', 'string', 'max:5000', ], // List of tag IDs 'tags' => [ 'nullable', 'array', ], 'tags.*' => [ 'string', ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], ]; } } ================================================ FILE: app/Http/Resources/PaginatedResourceCollection.php ================================================ > */ public function toArray(Request $request): array { return [ /** @var string $id ID of the API token, this ID is NOT a UUID */ 'id' => $this->resource->id, /** @var string $name Name of the API token */ 'name' => $this->resource->name, /** @var bool $revoked Whether the API token is revoked */ 'revoked' => $this->resource->revoked, /** @var array $scopes List of scopes that the API token has */ 'scopes' => $this->resource->scopes, /** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ 'created_at' => $this->formatDateTime($this->resource->created_at), /** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ 'expires_at' => $this->formatDateTime($this->resource->expires_at), ]; } } ================================================ FILE: app/Http/Resources/V1/ApiToken/ApiTokenWithAccessTokenResource.php ================================================ accessToken = $accessToken; parent::__construct($resource); } /** * Transform the resource into an array. * * @return array> */ public function toArray(Request $request): array { return [ /** @var string $id ID of the API token, this ID is NOT a UUID */ 'id' => $this->resource->id, /** @var string $name Name of the API token */ 'name' => $this->resource->name, /** @var bool $revoked Whether the API token is revoked */ 'revoked' => $this->resource->revoked, /** @var array $scopes List of scopes that the API token has */ 'scopes' => $this->resource->scopes, /** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ 'created_at' => $this->formatDateTime($this->resource->created_at), /** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ 'expires_at' => $this->formatDateTime($this->resource->expires_at), // Additional fields /** @var string $access_token Access token that can be used to authenticate requests */ 'access_token' => $this->accessToken, ]; } } ================================================ FILE: app/Http/Resources/V1/BaseResource.php ================================================ toIso8601ZuluString(); } protected function formatDate(?Carbon $carbon): ?string { return $carbon?->format('Y-m-d'); } } ================================================ FILE: app/Http/Resources/V1/Client/ClientCollection.php ================================================ */ public function toArray(Request $request): array { return [ /** @var string $id ID */ 'id' => $this->resource->id, /** @var string $name Name */ 'name' => $this->resource->name, /** @var bool $is_archived Whether the client is archived */ 'is_archived' => $this->resource->is_archived, /** @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), ]; } } ================================================ FILE: app/Http/Resources/V1/Invitation/InvitationCollection.php ================================================ > */ public function toArray(Request $request): array { return [ /** @var string $id ID of the invitation */ 'id' => $this->resource->id, /** @var string $email Email */ 'email' => $this->resource->email, /** @var string $role Role */ 'role' => $this->resource->role, ]; } } ================================================ FILE: app/Http/Resources/V1/Member/MemberCollection.php ================================================ > */ public function toArray(Request $request): array { return [ /** @var string $id ID of membership */ 'id' => $this->resource->id, /** @var string $id ID of user */ 'user_id' => $this->resource->user->id, /** @var string $name Name */ 'name' => $this->resource->user->name, /** @var string $email Email */ 'email' => $this->resource->user->email, /** @var string $role Role */ 'role' => $this->resource->role, /** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */ 'is_placeholder' => $this->resource->user->is_placeholder, /** @var int|null $billable_rate Billable rate in cents per hour */ 'billable_rate' => $this->resource->billable_rate, ]; } } ================================================ FILE: app/Http/Resources/V1/Member/PersonalMembershipCollection.php ================================================ > */ public function toArray(Request $request): array { return [ /** @var string $id ID of membership */ 'id' => $this->resource->id, 'organization' => [ /** @var string $id ID of organization */ 'id' => $this->resource->organization->id, /** @var string $name Name of organization */ 'name' => $this->resource->organization->name, /** @var string $currency Currency code (ISO 4217) of organization */ 'currency' => $this->resource->organization->currency, ], /** @var string $role Role */ 'role' => $this->resource->role, ]; } } ================================================ FILE: app/Http/Resources/V1/Organization/OrganizationResource.php ================================================ showBillableRate = $showBillableRate; } /** * Transform the resource into an array. * * @return array */ public function toArray(Request $request): array { $currencyService = app(CurrencyService::class); return [ /** @var string $id ID */ 'id' => $this->resource->id, /** @var string $name Name */ 'name' => $this->resource->name, /** @var bool $color Personal organizations automatically created after registration */ 'is_personal' => $this->resource->personal_team, /** @var int|null $billable_rate Billable rate in cents per hour */ 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, /** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */ 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates, /** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */ 'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks, /** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */ 'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries, /** @var string $currency Currency code (ISO 4217) */ 'currency' => $this->resource->currency, /** @var string $currency_symbol Currency symbol */ 'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency), /** @var NumberFormat $number_format Number format */ 'number_format' => $this->resource->number_format->value, /** @var CurrencyFormat $currency_format Currency format */ 'currency_format' => $this->resource->currency_format->value, /** @var DateFormat $date_format Date format */ 'date_format' => $this->resource->date_format->value, /** @var IntervalFormat $interval_format Interval format */ 'interval_format' => $this->resource->interval_format->value, /** @var TimeFormat $time_format Time format */ 'time_format' => $this->resource->time_format->value, ]; } } ================================================ FILE: app/Http/Resources/V1/Project/ProjectCollection.php ================================================ showBillableRates = $showBillableRates; } protected function collects(): ?string { return null; } /** * Transform the resource collection into an array. * * @return array> */ public function toArray(Request $request): array { return $this->collection->map(function (Project $project) use ($request): array { return (new ProjectResource($project, $this->showBillableRates)) ->toArray($request); })->all(); } } ================================================ FILE: app/Http/Resources/V1/Project/ProjectResource.php ================================================ showBillableRate = $showBillableRate; } /** * Transform the resource into an array. * * @return array */ public function toArray(Request $request): array { return [ /** @var string $id ID of project */ 'id' => $this->resource->id, /** @var string $name Name of project */ 'name' => $this->resource->name, /** @var string $color Color of project */ 'color' => $this->resource->color, /** @var string|null $client_id ID of client */ 'client_id' => $this->resource->client_id, /** @var bool $is_archived Whether the client is archived */ 'is_archived' => $this->resource->is_archived, /** @var int|null $billable_rate Billable rate in cents per hour */ 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, /** @var bool $is_billable Project time entries billable default */ 'is_billable' => $this->resource->is_billable, /** @var int|null $estimated_time Estimated time in seconds */ 'estimated_time' => $this->resource->estimated_time, /** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */ 'spent_time' => $this->resource->spent_time, /** @var bool $is_public Whether the project is public */ 'is_public' => $this->resource->is_public, ]; } } ================================================ FILE: app/Http/Resources/V1/ProjectMember/ProjectMemberCollection.php ================================================ */ public function toArray(Request $request): array { return [ /** @var string $id ID of project member */ 'id' => $this->resource->id, /** @var int|null $billable_rate Billable rate in cents per hour */ 'billable_rate' => $this->resource->billable_rate, /** @var string $member_id ID of the organization member */ 'member_id' => $this->resource->member_id, /** @var string $project_id ID of the project */ 'project_id' => $this->resource->project_id, ]; } } ================================================ FILE: app/Http/Resources/V1/Report/DetailedReportResource.php ================================================ >> */ public function toArray(Request $request): array { return [ /** @var string $id ID of the report */ 'id' => $this->resource->id, /** @var string $name Name */ 'name' => $this->resource->name, /** @var string|null $email Description */ 'description' => $this->resource->description, /** @var bool $is_public Whether the report can be accessed via an external link */ 'is_public' => $this->resource->is_public, /** @var string|null $public_until Date until the report is public */ 'public_until' => $this->formatDateTime($this->resource->public_until), /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ 'shareable_link' => $this->resource->getShareableLink(), 'properties' => [ /** @var string $group Type of first grouping */ 'group' => $this->resource->properties->group->value, /** @var string $sub_group Type of second grouping */ 'sub_group' => $this->resource->properties->subGroup->value, /** @var string $history_group Type of grouping of the historic aggregation (time chart) */ 'history_group' => $this->resource->properties->historyGroup->value, /** @var string $start Start date of the report */ 'start' => $this->formatDateTime($this->resource->properties->start), /** @var string $end End date of the report */ 'end' => $this->formatDateTime($this->resource->properties->end), /** @var bool|null $active Whether the report is active */ 'active' => $this->resource->properties->active, /** @var array|null $member_ids Filter by multiple member IDs, member IDs are OR combined */ 'member_ids' => $this->resource->properties->memberIds?->toArray(), /** @var bool|null $billable Filter by billable status */ 'billable' => $this->resource->properties->billable, /** @var array|null $client_ids Filter by client IDs, client IDs are OR combined */ 'client_ids' => $this->resource->properties->clientIds?->toArray(), /** @var array|null $project_ids Filter by project IDs, project IDs are OR combined */ 'project_ids' => $this->resource->properties->projectIds?->toArray(), /** @var array|null $tags_ids Filter by tag IDs, tag IDs are OR combined */ 'tag_ids' => $this->resource->properties->tagIds?->toArray(), /** @var array|null $task_ids Filter by task IDs, task IDs are OR combined */ 'task_ids' => $this->resource->properties->taskIds?->toArray(), /** @var string|null $rounding_type Rounding type for time entries */ 'rounding_type' => $this->resource->properties->roundingType?->value, /** @var int|null $rounding_minutes Rounding minutes for time entries */ 'rounding_minutes' => $this->resource->properties->roundingMinutes, ], /** @var string $created_at Date when the report was created */ 'created_at' => $this->formatDateTime($this->resource->created_at), /** @var string $updated_at Date when the report was last updated */ 'updated_at' => $this->formatDateTime($this->resource->updated_at), ]; } } ================================================ FILE: app/Http/Resources/V1/Report/DetailedWithDataReportResource.php ================================================ * }>, * seconds: int, * cost: int|null * } */ class DetailedWithDataReportResource extends BaseResource { /** * @var Data */ private array $data; /** * @var Data */ private array $historyData; /** * @param Data $data * @param Data $historyData */ public function __construct(Report $resource, array $data, array $historyData) { parent::__construct($resource); $this->data = $data; $this->historyData = $historyData; } /** * Transform the resource into an array. * * @return array>> */ public function toArray(Request $request): array { $currencyService = app(CurrencyService::class); return [ /** @var string $name Name */ 'name' => $this->resource->name, /** @var string|null $email Description */ 'description' => $this->resource->description, /** @var string|null $public_until Date until the report is public */ 'public_until' => $this->formatDateTime($this->resource->public_until), /** @var string $currency Currency code (ISO 4217) */ 'currency' => $this->resource->organization->currency, /** @var NumberFormat $number_format Number format */ 'number_format' => $this->resource->organization->number_format->value, /** @var CurrencyFormat $currency_format Currency format */ 'currency_format' => $this->resource->organization->currency_format->value, /** @var string $currency_symbol Currency symbol */ 'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->organization->currency), /** @var DateFormat $date_format Date format */ 'date_format' => $this->resource->organization->date_format->value, /** @var IntervalFormat $interval_format Interval format */ 'interval_format' => $this->resource->organization->interval_format->value, /** @var TimeFormat $time_format Time format */ 'time_format' => $this->resource->organization->time_format->value, 'properties' => [ /** @var string $group Type of first grouping */ 'group' => $this->resource->properties->group->value, /** @var string $sub_group Type of second grouping */ 'sub_group' => $this->resource->properties->subGroup->value, /** @var string $history_group Type of grouping of the historic aggregation (time chart) */ 'history_group' => $this->resource->properties->historyGroup->value, /** @var string $start Start date of the report */ 'start' => $this->formatDateTime($this->resource->properties->start), /** @var string $end End date of the report */ 'end' => $this->formatDateTime($this->resource->properties->end), ], /** @var array{ * grouped_type: string|null, * grouped_data: null|array * }>, * seconds: int, * cost: int * } $data Aggregated data */ 'data' => $this->data, /** @var array{ * grouped_type: string|null, * grouped_data: null|array * }>, * seconds: int, * cost: int * } $history_data Historic aggregated data */ 'history_data' => $this->historyData, ]; } } ================================================ FILE: app/Http/Resources/V1/Report/ReportCollection.php ================================================ > */ public function toArray(Request $request): array { return [ /** @var string $id ID of the report */ 'id' => $this->resource->id, /** @var string $name Name */ 'name' => $this->resource->name, /** @var string|null $email Description */ 'description' => $this->resource->description, /** @var bool $is_public Whether the report can be accessed via an external link */ 'is_public' => $this->resource->is_public, /** @var string|null $public_until Date until the report is public */ 'public_until' => $this->formatDateTime($this->resource->public_until), /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ 'shareable_link' => $this->resource->getShareableLink(), /** @var string $created_at Date when the report was created */ 'created_at' => $this->formatDateTime($this->resource->created_at), /** @var string $updated_at Date when the report was last updated */ 'updated_at' => $this->formatDateTime($this->resource->updated_at), ]; } } ================================================ FILE: app/Http/Resources/V1/Tag/TagCollection.php ================================================ */ 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 $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), ]; } } ================================================ FILE: app/Http/Resources/V1/Task/TaskCollection.php ================================================ */ public function toArray(Request $request): array { return [ /** @var string $id ID */ 'id' => $this->resource->id, /** @var string $name Name */ 'name' => $this->resource->name, /** @var bool $is_done Whether the task is done */ 'is_done' => $this->resource->is_done, /** @var string $project_id ID of the project */ 'project_id' => $this->resource->project_id, /** @var int|null $estimated_time Estimated time in seconds */ 'estimated_time' => $this->resource->estimated_time, /** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */ 'spent_time' => $this->resource->spent_time, /** @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), ]; } } ================================================ FILE: app/Http/Resources/V1/TimeEntry/TimeEntryCollection.php ================================================ > */ public function toArray(Request $request): array { return [ /** @var string $id ID of time entry */ 'id' => $this->resource->id, /** * @var string $start Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ 'start' => $this->formatDateTime($this->resource->start), /** * @var string|null $end End of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ 'end' => $this->formatDateTime($this->resource->end), /** @var int|null $duration Duration of time entry in seconds */ 'duration' => (int) $this->resource->getDuration()?->totalSeconds, /** @var string|null $description Description of time entry */ 'description' => $this->resource->description, /** @var string|null $task_id ID of task */ 'task_id' => $this->resource->task_id, /** @var string|null $project_id ID of project */ 'project_id' => $this->resource->project_id, /** @var string $organization_id ID of organization */ 'organization_id' => $this->resource->organization_id, /** @var string $user_id ID of user */ 'user_id' => $this->resource->user_id, /** @var array $tags List of tag IDs */ 'tags' => $this->resource->tags ?? [], /** @var bool $billable Whether time entry is billable */ 'billable' => $this->resource->billable, ]; } } ================================================ FILE: app/Http/Resources/V1/User/UserResource.php ================================================ > */ public function toArray(Request $request): array { return [ /** @var string $id ID of user */ 'id' => $this->resource->id, /** @var string $name Name of user */ 'name' => $this->resource->name, /** @var string $email Email of user */ 'email' => $this->resource->email, /** @var string $profile_photo_url Profile photo URL */ 'profile_photo_url' => $this->resource->profile_photo_url, /** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */ 'timezone' => $this->resource->timezone, /** @var Weekday $week_start Starting day of the week */ 'week_start' => $this->resource->week_start->value, ]; } } ================================================ FILE: app/Jobs/RecalculateSpentTimeForProject.php ================================================ project = $project; } /** * Execute the job. * * @throws Exception */ public function handle(): void { $this->project->setComputedAttributeValue('spent_time'); if ($this->project->isDirty()) { $this->project->save(); } } } ================================================ FILE: app/Jobs/RecalculateSpentTimeForTask.php ================================================ task = $task; } /** * Execute the job. * * @throws Exception */ public function handle(): void { $this->task->setComputedAttributeValue('spent_time'); if ($this->task->isDirty()) { $this->task->save(); } } } ================================================ FILE: app/Jobs/Test/TestJob.php ================================================ user = $user; $this->message = $message; $this->fail = $fail; } /** * Execute the job. * * @throws Exception */ public function handle(): void { Log::debug('TestJob: '.$this->message, [ 'user' => $this->user->getKey(), ]); if ($this->fail) { throw new Exception('TestJob failed.'); } } } ================================================ FILE: app/Listeners/RemovePlaceholder.php ================================================ whereBelongsTo($event->team, 'organization') ->whereBelongsTo($event->user, 'user') ->firstOrFail(); $placeholders = Member::query() ->whereHas('user', function (Builder $query) use ($event): void { /** @var Builder $query */ $query->where('is_placeholder', '=', true) ->where('email', '=', $event->user->email); }) ->whereBelongsTo($event->team, 'organization') ->with(['user']) ->get(); foreach ($placeholders as $placeholder) { /** @var Member $placeholder */ $placeholderUser = $placeholder->user; $memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member); $placeholder->delete(); $placeholderUser->delete(); } } } ================================================ FILE: app/Mail/AuthApiTokenExpirationReminderMail.php ================================================ 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!')); } } ================================================ FILE: app/Mail/AuthApiTokenExpiredMail.php ================================================ 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!')); } } ================================================ FILE: app/Mail/OrganizationInvitationMail.php ================================================ invitation = $invitation; } /** * Build the message. */ public function build(): self { return $this->markdown('emails.organization-invitation', [ 'acceptUrl' => URL::signedRoute('team-invitations.accept', [ 'invitation' => $this->invitation, ]), ])->subject(__('Organization Invitation')); } } ================================================ FILE: app/Mail/TimeEntryStillRunningMail.php ================================================ timeEntry = $timeEntry; $this->user = $user; } /** * Build the message. */ public function build(): self { return $this->markdown('emails.time-entry-still-running', [ 'dashboardUrl' => URL::route('dashboard'), ]) ->subject(__('Your Time Tracker is still running!')); } } ================================================ FILE: app/Models/Audit.php ================================================ |null $old_values * @property array|null $new_values * @property string|null $url * @property string|null $ip_address * @property string|null $user_agent * @property string|null $tags * @property Carbon|null $created_at * @property Carbon|null $updated_at * * @method static AuditFactory factory() */ class Audit extends PackageAuditModel { /** @use HasFactory */ use HasFactory; } ================================================ FILE: app/Models/Client.php ================================================ */ use HasFactory; use HasUuids; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'name' => 'string', 'archived_at' => 'datetime', ]; /** * @return BelongsTo */ public function organization(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } /** * @return HasMany */ public function projects(): HasMany { return $this->hasMany(Project::class, 'client_id'); } /** * @param Builder $builder * @return Builder */ public function scopeVisibleByEmployee(Builder $builder, User $user): Builder { return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder { /** @var Builder $builder */ return $builder->visibleByEmployee($user); }); } /** * @return Attribute */ protected function isArchived(): Attribute { return Attribute::make( get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']), ); } } ================================================ FILE: app/Models/Concerns/CustomAuditable.php ================================================ |null */ protected ?array $auditEvents = null; public function disableAuditing(): void { $this->auditEvents = []; } } ================================================ FILE: app/Models/Concerns/HasUuids.php ================================================ */ use HasFactory; /** * Indicates if the model should be timestamped. * * @var bool */ public $timestamps = false; /** * The attributes that should be cast to native types. * * @var array */ protected $casts = [ 'failed_at' => 'datetime', 'payload' => 'json', ]; } ================================================ FILE: app/Models/Member.php ================================================ $projectMembers * @property-read Collection $timeEntries * * @method static MemberFactory factory() */ class Member extends JetstreamMembership implements AuditableContract { use CustomAuditable; /** @use HasFactory */ use HasFactory; use HasUuids; /** * The table associated with the pivot model. * * @var string */ protected $table = 'members'; /** * @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } /** * @return BelongsTo */ public function organization(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } /** * @return HasMany */ public function timeEntries(): HasMany { return $this->hasMany(TimeEntry::class, 'member_id'); } /** * @return HasMany */ public function projectMembers(): HasMany { return $this->hasMany(ProjectMember::class, 'member_id'); } } ================================================ FILE: app/Models/Organization.php ================================================ $users * @property Collection $realUsers * @property-read Collection $teamInvitations * @property Member $membership * @property NumberFormat $number_format * @property CurrencyFormat $currency_format * @property DateFormat $date_format * @property IntervalFormat $interval_format * @property TimeFormat $time_format * * @method HasMany teamInvitations() * @method static OrganizationFactory factory() */ class Organization extends JetstreamTeam implements AuditableContract { use CustomAuditable; /** @use HasFactory */ use HasFactory; use HasUuids; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'name' => 'string', 'personal_team' => 'boolean', 'currency' => 'string', 'employees_can_see_billable_rates' => 'boolean', 'employees_can_manage_tasks' => 'boolean', 'prevent_overlapping_time_entries' => 'boolean', 'number_format' => NumberFormat::class, 'currency_format' => CurrencyFormat::class, 'date_format' => DateFormat::class, 'interval_format' => IntervalFormat::class, 'time_format' => TimeFormat::class, ]; /** * The attributes that are mass assignable. * * @var list */ protected $fillable = [ 'name', 'personal_team', ]; /** * The event map for the model. * * @var array */ protected $dispatchesEvents = [ 'created' => TeamCreated::class, 'updated' => TeamUpdated::class, 'deleted' => TeamDeleted::class, ]; /** * The model's default values for attributes. * * @var array */ protected $attributes = [ ]; /** * Get all the non-placeholder users of the organization including its owner. * * @return Collection */ public function allRealUsers(): Collection { return $this->realUsers->merge([$this->owner]); } public function hasRealUserWithEmail(string $email): bool { return $this->allRealUsers()->contains(function (User $user) use ($email): bool { return $user->email === $email; }); } /** * Get all the users that belong to the team. * * @return BelongsToMany */ public function users(): BelongsToMany { return $this->belongsToMany(User::class, Member::class) ->withPivot([ 'id', 'role', 'billable_rate', ]) ->withTimestamps() ->as('membership'); } /** * Get the owner of the team. * * @return BelongsTo */ public function owner(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } /** * @return HasMany */ public function members(): HasMany { return $this->hasMany(Member::class); } /** * @return BelongsToMany */ public function realUsers(): BelongsToMany { return $this->users() ->where('is_placeholder', false); } /** * This method prevents an unhandled exception when the ID is not a UUID. * Normally this can be fixed with a route pattern, but Jetstream does not use route model binding. * * @param array $columns */ public function findOrFail(string $id, array $columns = ['*']): \Laravel\Jetstream\Team { if (! Str::isUuid($id)) { throw (new ModelNotFoundException)->setModel( self::class, $id ); } return parent::findOrFail($id, $columns); } } ================================================ FILE: app/Models/OrganizationInvitation.php ================================================ */ use HasFactory; use HasUuids; /** * The table associated with the model. * * @var string */ protected $table = 'organization_invitations'; /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ 'email', 'role', ]; /** * Get the organization that the invitation belongs to. * * @return BelongsTo */ public function organization(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } /** * Get the organization that the invitation belongs to. * * @return BelongsTo */ public function team(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } } ================================================ FILE: app/Models/Passport/AuthCode.php ================================================ */ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } } ================================================ FILE: app/Models/Passport/Client.php ================================================ $grant_types * @property array $redirect_uris * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property bool $revoked */ class Client extends PassportClient { /** @use HasFactory */ use HasFactory; /** * Create a new factory instance for the model. * * @return ClientFactory */ protected static function newFactory(): Factory { return ClientFactory::new(); } } ================================================ FILE: app/Models/Passport/RefreshToken.php ================================================ $scopes * @property bool $revoked * @property Carbon|null $reminder_sent_at * @property Carbon|null $expired_info_sent_at * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property Carbon|null $expires_at * @property-read Client|null $client * @property-read User|null $user * * @method Builder isApiToken(bool $isApiToken = true) */ class Token extends PassportToken { /** @use HasFactory */ use HasFactory; /** * Get the client that the token belongs to. * * @return BelongsTo */ // @phpstan-ignore method.childReturnType public function client(): BelongsTo { return $this->belongsTo(Client::class, 'client_id', 'id'); } /** * Get the user that the token belongs to. * * @deprecated Will be removed in a future Laravel version. * * @return BelongsTo */ // @phpstan-ignore method.childReturnType public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } /** * Get the attributes that should be cast. * * @return array */ protected function casts(): array { return [ 'scopes' => 'array', 'revoked' => 'bool', 'expires_at' => 'datetime', 'reminder_sent_at' => 'datetime', 'expired_info_sent_at' => 'datetime', ]; } /** * @param Builder $query * @return Builder */ public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder { if ($isApiToken) { return $query->whereHas('client', function (Builder $query): void { /** @var Builder $query */ $query->whereJsonContains('grant_types', 'personal_access'); }); } else { return $query->whereHas('client', function (Builder $query): void { /** @var Builder $query */ $query->whereJsonDoesntContain('grant_types', 'personal_access'); }); } } } ================================================ FILE: app/Models/Project.php ================================================ $tasks * @property-read Collection $members * * @method Builder visibleByEmployee(User $user) * @method static ProjectFactory factory() */ class Project extends Model implements AuditableContract { use ComputedAttributes; use CustomAuditable; /** @use HasFactory */ use HasFactory; use HasUuids; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'name' => 'string', 'color' => 'string', 'archived_at' => 'datetime', 'estimated_time' => 'integer', 'spent_time' => 'integer', ]; /** * Set default values for attributes. * * @var array */ protected $attributes = [ 'is_billable' => false, ]; /** * The attributes that are computed. (f.e. for performance reasons) * These attributes can be regenerated at any time. * * @var string[] */ protected array $computed = [ 'spent_time', ]; /** * Attributes to exclude from the Audit. * * @var array */ protected array $auditExclude = [ 'spent_time', ]; public function getSpentTimeComputed(): ?int { if ($this->hasAttribute('spent_time_computed')) { return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed']; } else { /** @var object{ spent_time: string } $result */ $result = $this->timeEntries() ->whereNotNull('end') ->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time') ->first(); return (int) $result->spent_time; } } /** * This scope will be applied during the computed property generation with artisan computed-attributes:generate. * * @param Builder $builder * @param array $attributes Attributes that will be generated. * @return Builder */ public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder { if (in_array('spent_time', $attributes, true)) { $builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum'); } return $builder; } /** * This scope will be applied during the computed property validation with artisan computed-attributes:validate. * * @param Builder $builder * @param array $attributes Attributes that will be validated. * @return Builder */ public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder { return $this->scopeComputedAttributesGenerate($builder, $attributes); } /** * @return BelongsTo */ public function organization(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } /** * @return BelongsTo */ public function client(): BelongsTo { return $this->belongsTo(Client::class, 'client_id'); } /** * @return HasMany */ public function members(): HasMany { return $this->hasMany(ProjectMember::class, 'project_id'); } /** * @return HasMany */ public function tasks(): HasMany { return $this->hasMany(Task::class); } /** * @return HasMany */ public function timeEntries(): HasMany { return $this->hasMany(TimeEntry::class, 'project_id'); } /** * @param Builder $builder */ public function scopeVisibleByEmployee(Builder $builder, User $user): void { $builder->where(function (Builder $builder) use ($user): Builder { return $builder->where('is_public', '=', true) ->orWhereHas('members', function (Builder $builder) use ($user): Builder { return $builder->whereBelongsTo($user, 'user'); }); }); } /** * @return Attribute */ protected function isArchived(): Attribute { return Attribute::make( get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']), ); } } ================================================ FILE: app/Models/ProjectMember.php ================================================ whereBelongsToOrganization(Organization $organization) * @method static ProjectMemberFactory factory() */ class ProjectMember extends Model implements AuditableContract { use CustomAuditable; /** @use HasFactory */ use HasFactory; use HasUuids; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'billable_rate' => 'int', ]; /** * @return BelongsTo */ public function project(): BelongsTo { return $this->belongsTo(Project::class, 'project_id'); } /** * @deprecated Use member relationship instead * * @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } /** * @return BelongsTo */ public function member(): BelongsTo { return $this->belongsTo(Member::class, 'member_id'); } /** * @param Builder $builder */ public function scopeWhereBelongsToOrganization(Builder $builder, Organization $organization): void { $builder->whereHas('project', static function (Builder $query) use ($organization): void { $query->whereBelongsTo($organization, 'organization'); }); } } ================================================ FILE: app/Models/Report.php ================================================ */ use HasFactory; use HasUuids; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'is_public' => 'bool', 'public_until' => 'datetime', 'properties' => ReportPropertiesDto::class, ]; public function getShareableLink(): ?string { if ($this->is_public && $this->share_secret !== null) { return route('shared-report').'#'.$this->share_secret; } return null; } /** * @return BelongsTo */ public function organization(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } } ================================================ FILE: app/Models/Tag.php ================================================ $timeEntries * @property-read Organization $organization * * @method static TagFactory factory() */ class Tag extends Model implements AuditableContract { use CustomAuditable; /** @use HasFactory */ use HasFactory; use HasJsonRelationships; use HasUuids; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'name' => 'string', ]; /** * @return BelongsTo */ public function organization(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } /** * Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it. * * @return HasManyJson */ public function timeEntries(): HasManyJson { return $this->hasManyJson(TimeEntry::class, 'tags'); } } ================================================ FILE: app/Models/Task.php ================================================ $timeEntries * @property-read bool $is_done * * @method static TaskFactory factory() */ class Task extends Model implements AuditableContract { use ComputedAttributes; use CustomAuditable; /** @use HasFactory */ use HasFactory; use HasUuids; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'name' => 'string', 'estimated_time' => 'integer', 'done_at' => 'datetime', ]; /** * The attributes that are computed. (f.e. for performance reasons) * These attributes can be regenerated at any time. * * @var string[] */ protected array $computed = [ 'spent_time', ]; /** * Attributes to exclude from the Audit. * * @var array */ protected array $auditExclude = [ 'spent_time', ]; public function getSpentTimeComputed(): ?int { if ($this->hasAttribute('spent_time_computed')) { return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed']; } else { /** @var object{ spent_time: string } $result */ $result = $this->timeEntries() ->whereNotNull('end') ->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time') ->first(); return (int) $result->spent_time; } } /** * This scope will be applied during the computed property generation with artisan computed-attributes:generate. * * @param Builder $builder * @param array $attributes Attributes that will be generated. * @return Builder */ public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder { if (in_array('spent_time', $attributes, true)) { $builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum'); } return $builder; } /** * This scope will be applied during the computed property validation with artisan computed-attributes:validate. * * @param Builder $builder * @param array $attributes Attributes that will be validated. * @return Builder */ public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder { return $this->scopeComputedAttributesGenerate($builder, $attributes); } /** * @return BelongsTo */ public function project(): BelongsTo { return $this->belongsTo(Project::class); } /** * @return BelongsTo */ public function organization(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } /** * @return HasMany */ public function timeEntries(): HasMany { return $this->hasMany(TimeEntry::class, 'task_id'); } /** * @param Builder $builder * @return Builder */ public function scopeVisibleByEmployee(Builder $builder, User $user): Builder { return $builder->whereHas('project', function (Builder $builder) use ($user): Builder { /** @var Builder $builder */ return $builder->visibleByEmployee($user); }); } /** * @return Attribute */ public function isDone(): Attribute { return Attribute::make( get: fn (mixed $value, array $attributes) => isset($attributes['done_at']), ); } } ================================================ FILE: app/Models/TimeEntry.php ================================================ $tags * @property string $user_id * @property string $member_id * @property bool $is_imported * @property Carbon|null $still_active_email_sent_at * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property-read User $user * @property-read Member $member * @property string $organization_id * @property-read Organization $organization * @property string|null $project_id * @property-read Project|null $project * @property string|null $client_id * @property-read Client|null $client * @property string|null $task_id * @property-read Task|null $task * @property-read Collection $tagsRelation * * @method Builder hasTag(Tag $tag) * @method static TimeEntryFactory factory() */ class TimeEntry extends Model implements AuditableContract { use ComputedAttributes; use CustomAuditable; /** @use HasFactory */ use HasFactory; use HasJsonRelationships; use HasUuids; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'description' => 'string', 'start' => 'datetime', 'end' => 'datetime', 'billable' => 'bool', 'tags' => 'array', 'billable_rate' => 'int', 'is_imported' => 'bool', 'still_active_email_sent_at' => 'datetime', ]; public const array SELECT_COLUMNS = [ 'id', 'description', 'start', 'end', 'billable_rate', 'billable', 'user_id', 'organization_id', 'project_id', 'task_id', 'tags', 'created_at', 'updated_at', 'member_id', 'client_id', 'is_imported', 'still_active_email_sent_at', ]; /** * The attributes that are computed. (f.e. for performance reasons) * These attributes can be regenerated at any time. * * @var string[] */ protected array $computed = [ 'billable_rate', 'client_id', ]; /** * Attributes to exclude from the Audit. * * @var array */ protected array $auditExclude = [ 'billable_rate', ]; public function getBillableRateComputed(): ?int { return app(BillableRateService::class)->getBillableRateForTimeEntry($this); } public function getClientIdComputed(): ?string { return $this->project_id === null || $this->project === null ? null : $this->project->client_id; } /** * This scope will be applied during the computed property generation with artisan computed-attributes:generate. * * @param Builder $builder * @param array $attributes Attributes that will be generated. * @return Builder */ public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder { if (in_array('client_id', $attributes, true)) { $builder->with([ 'project' => function (Relation $builder): void { /** @var Builder $builder */ $builder->select('id', 'client_id'); }, ]); } return $builder; } /** * This scope will be applied during the computed property validation with artisan computed-attributes:validate. * * @param Builder $builder * @param array $attributes Attributes that will be validated. * @return Builder */ public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder { return $this->scopeComputedAttributesGenerate($builder, $attributes); } public function getDuration(): ?CarbonInterval { return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end); } /** * @param Builder $builder */ public function scopeHasTag(Builder $builder, Tag $tag): void { $builder->whereJsonContains('tags', $tag->getKey()); } /** * @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } /** * @return BelongsTo */ public function member(): BelongsTo { return $this->belongsTo(Member::class, 'member_id'); } /** * @return BelongsTo */ public function organization(): BelongsTo { return $this->belongsTo(Organization::class, 'organization_id'); } /** * @return BelongsTo */ public function project(): BelongsTo { return $this->belongsTo(Project::class, 'project_id'); } /** * @return BelongsTo */ public function task(): BelongsTo { return $this->belongsTo(Task::class, 'task_id'); } /** * This relation can be reconstructed via the task relation. It is only here for performance reasons. * * @return BelongsTo */ public function client(): BelongsTo { return $this->belongsTo(Client::class, 'client_id'); } /** * Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it. * * @return BelongsToJson */ public function tagsRelation(): BelongsToJson { return $this->belongsToJson(Tag::class, 'tags'); } } ================================================ FILE: app/Models/User.php ================================================ $tokens * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property string|null $current_team_id * @property Collection $organizations * @property Collection $timeEntries * @property Member $membership * * @method HasMany ownedTeams() * @method static UserFactory factory() * @method static Builder query() * @method Builder belongsToOrganization(Organization $organization) * @method Builder active() */ class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail, OAuthenticatable { use CustomAuditable; use HasApiTokens; /** @use HasFactory */ use HasFactory; use HasProfilePhoto; use HasTeams; use HasUuids; use Notifiable; use TwoFactorAuthenticatable; /** * The attributes that are mass assignable. * * @var list */ protected $fillable = [ 'name', 'email', 'password', ]; /** * The attributes that should be hidden for serialization. * * @var list */ protected $hidden = [ 'password', 'remember_token', 'two_factor_recovery_codes', 'two_factor_secret', ]; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'name' => 'string', 'email' => 'string', '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, ]; /** * Get the URL to the user's profile photo. * * @return Attribute */ protected function profilePhotoUrl(): Attribute { return Attribute::get(function (): string { return $this->profile_photo_path ? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path) : $this->defaultProfilePhotoUrl(); }); } public function canAccessPanel(Panel $panel): bool { return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail(); } public function canBeImpersonated(): bool { return $this->is_placeholder === false; } /** * @return BelongsToMany */ public function organizations(): BelongsToMany { return $this->belongsToMany(Organization::class, Member::class) ->withPivot([ 'id', 'role', 'billable_rate', ]) ->withTimestamps() ->as('membership'); } /** * @return HasMany */ public function timeEntries(): HasMany { return $this->hasMany(TimeEntry::class); } /** * @return BelongsTo */ public function currentOrganization(): BelongsTo { return $this->belongsTo(Organization::class, 'current_team_id'); } /** * @return HasMany */ public function projectMembers(): HasMany { return $this->hasMany(ProjectMember::class, 'user_id'); } /** * @return HasMany */ public function accessTokens(): HasMany { return $this->hasMany(Token::class); } /** * @return HasMany */ public function authCodes(): HasMany { return $this->hasMany(AuthCode::class); } /** * @param Builder $builder */ public function scopeActive(Builder $builder): void { $builder->where('is_placeholder', '=', false); } /** * @param Builder $builder * @return Builder */ public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder { return $builder->where(function (Builder $builder) use ($organization): Builder { return $builder->whereHas('organizations', function (Builder $query) use ($organization): void { $query->whereKey($organization->getKey()); })->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void { $query->whereKey($organization->getKey()); }); }); } } ================================================ FILE: app/Policies/OrganizationPolicy.php ================================================ belongsToTeam($organization); } /** * Determine whether the user can create models. */ public function create(User $user): bool { if (Filament::isServing()) { return true; } return true; } /** * Determine whether the user can update the model. */ public function update(User $user, Organization $organization): bool { if (Filament::isServing()) { return true; } return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update'); } /** * Determine whether the user can add team members. */ public function addTeamMember(User $user, Organization $organization): bool { if (Filament::isServing()) { return true; } return true; } /** * Determine whether the user can update team member permissions. */ public function updateTeamMember(User $user, Organization $organization): bool { if (Filament::isServing()) { return true; } // Note: since this policy is only used for jetstream endpoints, we can return false here return false; } /** * Determine whether the user can remove team members. */ public function removeTeamMember(User $user, Organization $organization): bool { if (Filament::isServing()) { return true; } // Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here return false; } /** * Determine whether the user can delete the model. */ public function delete(User $user, Organization $organization): bool { if (Filament::isServing()) { return true; } return $user->ownsTeam($organization); } } ================================================ FILE: app/Providers/AppServiceProvider.php ================================================ app->environment('local')) { $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class); $this->app->register(TelescopeServiceProvider::class); } // Eloquent Model::preventLazyLoading(! $this->app->isProduction()); Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction()); Model::preventAccessingMissingAttributes(! $this->app->isProduction()); Relation::enforceMorphMap([ 'client' => Client::class, 'failed-job' => FailedJob::class, 'membership' => Member::class, 'organization' => Organization::class, 'organization-invitation' => OrganizationInvitation::class, 'project' => Project::class, 'project-member' => ProjectMember::class, 'tag' => Tag::class, 'task' => Task::class, 'time-entry' => TimeEntry::class, 'user' => User::class, ]); Model::unguard(); // Filament Section::configureUsing(function (Section $section): void { $section->columns(1); }, null, true); Table::configureUsing(function (Table $table): void { $table->paginated([10, 25, 50, 100]); }); // Scramble Scramble::extendOpenApi(function (OpenApi $openApi): void { $openApi->secure( SecurityScheme::oauth2() ->flow('authorizationCode', function (OAuthFlow $flow): void { $flow ->authorizationUrl('https://solidtime.test/oauth/authorize'); }) ); }); $this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore { return new PermissionStore; }); // Extensions $this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class); $this->app->bind(BillingContract::class); // Routing Route::model('member', Member::class); Route::model('invitation', OrganizationInvitation::class); Route::model('apiToken', Token::class); } } ================================================ FILE: app/Providers/AuthServiceProvider.php ================================================ */ protected $policies = [ Organization::class => OrganizationPolicy::class, ]; /** * Register any authentication / authorization services. */ public function boot(): void { // define scopes for passport tokens Passport::tokensCan([ 'create' => 'Create resources', 'read' => 'Read Resources', 'update' => 'Update Resources', 'delete' => 'Delete Resources', ]); // default scope for passport tokens Passport::setDefaultScope([ // 'create', 'read', // 'update', // 'delete', ]); Passport::useTokenModel(Token::class); Passport::useRefreshTokenModel(RefreshToken::class); Passport::useAuthCodeModel(AuthCode::class); Passport::useClientModel(Client::class); Passport::authorizationView('auth.oauth.authorize'); // Passport::tokensExpireIn(now()->addDays(15)); // Passport::refreshTokensExpireIn(now()->addDays(30)); Passport::personalAccessTokensExpireIn(now()->addMonths(12)); // same as passport default above Jetstream::defaultApiTokenPermissions(['read']); // use passport scopes for jetstream token permissions Jetstream::permissions(Passport::scopeIds()); } } ================================================ FILE: app/Providers/EventServiceProvider.php ================================================ > */ protected $listen = [ Registered::class => [ SendEmailVerificationNotification::class, ], TeamMemberAdded::class => [ RemovePlaceholder::class, ], ]; /** * Register any events for your application. */ public function boot(): void { // } /** * Determine if events and listeners should be automatically discovered. */ public function shouldDiscoverEvents(): bool { return false; } } ================================================ FILE: app/Providers/Filament/AdminPanelProvider.php ================================================ default() ->id('admin') ->path('admin') ->colors([ 'primary' => Color::Amber, ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') ->pages([ Pages\Dashboard::class, ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->widgets([ ServerOverview::class, ActiveUserOverview::class, UserRegistrations::class, TimeEntriesCreated::class, TimeEntriesImported::class, ]) ->viteTheme('resources/css/filament/admin/theme.css') ->plugins([ EnvironmentIndicatorPlugin::make() ->color(fn () => match (App::environment()) { 'production' => null, 'staging' => Color::Orange, default => Color::Blue, }), ]) ->navigationGroups([ NavigationGroup::make() ->label('Timetracking'), NavigationGroup::make() ->label('Users') ->collapsed(), NavigationGroup::make() ->label('System') ->collapsed(), NavigationGroup::make() ->label('Auth') ->collapsed(), ]) ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, AuthenticateSession::class, ShareErrorsFromSession::class, VerifyCsrfToken::class, SubstituteBindings::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) ->authMiddleware([ Authenticate::class, ]); $modules = Module::allEnabled(); foreach ($modules as $module) { $panel->discoverResources( in: module_path($module->getName(), 'app/Filament/Resources'), for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources' ); $panel->discoverPages( in: module_path($module->getName(), 'app/Filament/Pages'), for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages' ); $panel->discoverWidgets( in: module_path($module->getName(), 'app/Filament/Widgets'), for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets' ); } return $panel; } } ================================================ FILE: app/Providers/FortifyServiceProvider.php ================================================ where('email', $request->email) ->where('is_placeholder', '=', false) ->first(); if ($user !== null && Hash::check($request->password, $user->password)) { return $user; } return null; }); RateLimiter::for('login', function (Request $request) { $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); return Limit::perMinute(5)->by($throttleKey); }); RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); }); $this->app->instance(LoginResponse::class, new CustomLoginResponse); $this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse); } } ================================================ FILE: app/Providers/JetstreamServiceProvider.php ================================================ configurePermissions(); Jetstream::createTeamsUsing(CreateOrganization::class); Jetstream::updateTeamNamesUsing(UpdateOrganization::class); Jetstream::addTeamMembersUsing(AddOrganizationMember::class); Jetstream::inviteTeamMembersUsing(InviteOrganizationMember::class); Jetstream::removeTeamMembersUsing(RemoveOrganizationMember::class); Jetstream::deleteTeamsUsing(DeleteOrganization::class); Jetstream::deleteUsersUsing(DeleteUser::class); Jetstream::useTeamModel(Organization::class); Jetstream::useMembershipModel(Member::class); Jetstream::useTeamInvitationModel(OrganizationInvitation::class); app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class); app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class); Fortify::registerView(function () { return Inertia::render('Auth/Register', [ 'terms_url' => config('auth.terms_url'), 'privacy_policy_url' => config('auth.privacy_policy_url'), 'newsletter_consent' => config('auth.newsletter_consent'), ]); }); Gate::define('removeTeamMember', function (User $user, Organization $team) { return false; }); } /** * Configure the roles and permissions that are available within the application. */ protected function configurePermissions(): void { Jetstream::defaultApiTokenPermissions([]); Jetstream::role(Role::Owner->value, 'Owner', [ 'charts:view:own', 'charts:view:all', 'projects:view', 'projects:view:all', 'projects:create', 'projects:update', 'projects:delete', 'project-members:view', 'project-members:create', 'project-members:update', 'project-members:delete', 'tasks:view', 'tasks:view:all', 'tasks:create', 'tasks:create:all', 'tasks:update', 'tasks:update:all', 'tasks:delete', 'tasks:delete:all', 'time-entries:view:all', 'time-entries:create:all', 'time-entries:update:all', 'time-entries:delete:all', 'time-entries:view:own', 'time-entries:create:own', 'time-entries:update:own', 'time-entries:delete:own', 'tags:view', 'tags:create', 'tags:update', 'tags:delete', 'clients:view', 'clients:view:all', 'clients:create', 'clients:update', 'clients:delete', 'organizations:view', 'organizations:update', 'organizations:delete', 'import', 'export', 'invitations:view', 'invitations:create', 'invitations:resend', 'invitations:remove', 'members:view', 'members:invite-placeholder', 'members:change-ownership', 'members:make-placeholder', 'members:merge-into', 'members:update', 'members:delete', 'billing', 'reports:view', 'reports:create', 'reports:update', 'reports:delete', 'invoices:view', 'invoices:create', 'invoices:update', 'invoices:download', 'invoices:delete', 'invoice-settings:view', 'invoice-settings:update', ])->description('Owner users can perform any action. There is only one owner per organization.'); Jetstream::role(Role::Admin->value, 'Administrator', [ 'charts:view:own', 'charts:view:all', 'projects:view', 'projects:view:all', 'projects:create', 'projects:update', 'projects:delete', 'project-members:view', 'project-members:create', 'project-members:update', 'project-members:delete', 'tasks:view', 'tasks:view:all', 'tasks:create', 'tasks:create:all', 'tasks:update', 'tasks:update:all', 'tasks:delete', 'tasks:delete:all', 'time-entries:view:all', 'time-entries:create:all', 'time-entries:update:all', 'time-entries:delete:all', 'time-entries:view:own', 'time-entries:create:own', 'time-entries:update:own', 'time-entries:delete:own', 'tags:view', 'tags:create', 'tags:update', 'tags:delete', 'clients:view', 'clients:view:all', 'clients:create', 'clients:update', 'clients:delete', 'organizations:view', 'organizations:update', 'import', 'export', 'invitations:view', 'invitations:create', 'invitations:resend', 'invitations:remove', 'members:view', 'members:invite-placeholder', 'members:make-placeholder', 'members:merge-into', 'members:delete', 'members:update', 'reports:view', 'reports:create', 'reports:update', 'reports:delete', 'invoices:view', 'invoices:create', 'invoices:update', 'invoices:download', 'invoices:delete', 'invoice-settings:view', 'invoice-settings:update', ])->description('Administrator users can perform any action, except accessing the billing dashboard.'); Jetstream::role(Role::Manager->value, 'Manager', [ 'charts:view:own', 'charts:view:all', 'projects:view', 'projects:view:all', 'projects:create', 'projects:update', 'projects:delete', 'project-members:view', 'project-members:create', 'project-members:update', 'project-members:delete', 'tasks:view', 'tasks:view:all', 'tasks:create', 'tasks:create:all', 'tasks:update', 'tasks:update:all', 'tasks:delete', 'tasks:delete:all', 'time-entries:view:all', 'time-entries:create:all', 'time-entries:update:all', 'time-entries:delete:all', 'time-entries:view:own', 'time-entries:create:own', 'time-entries:update:own', 'time-entries:delete:own', 'tags:view', 'tags:create', 'tags:update', 'tags:delete', 'clients:view', 'clients:view:all', 'clients:create', 'clients:update', 'clients:delete', 'organizations:view', 'invitations:view', 'members:view', 'reports:view', 'reports:create', 'reports:update', 'reports:delete', 'invoices:view', 'invoices:create', 'invoices:update', 'invoices:download', 'invoices:delete', 'invoice-settings:view', 'invoice-settings:update', ])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).'); Jetstream::role(Role::Employee->value, 'Employee', [ 'charts:view:own', 'projects:view', 'tags:view', 'tasks:view', 'clients:view', 'time-entries:view:own', 'time-entries:create:own', 'time-entries:update:own', 'time-entries:delete:own', 'organizations:view', ])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.'); Jetstream::role(Role::Placeholder->value, 'Placeholder', [ ])->description('Placeholders are used for importing data. They cannot log in and have no permissions.'); Jetstream::inertia() ->whenRendering( 'Profile/Show', function (Request $request, array $data): array { return array_merge($data, [ 'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(), 'weekdays' => Weekday::toSelectArray(), ]); } ) ->whenRendering( 'Teams/Show', function (Request $request, array $data): array { /** @var Organization $teamModel */ $teamModel = $data['team']; $owner = $teamModel->owner; return array_merge($data, [ 'team' => [ 'id' => $teamModel->getKey(), 'name' => $teamModel->name, 'currency' => $teamModel->currency, 'owner' => [ 'id' => $owner->getKey(), 'name' => $owner->name, 'email' => $owner->email, 'profile_photo_url' => $owner->profile_photo_url, ], 'users' => $teamModel->users->map(function (User $user): array { return [ 'id' => $user->getKey(), 'name' => $user->name, 'email' => $user->email, 'profile_photo_url' => $user->profile_photo_url, 'membership' => [ 'id' => $user->membership->id, 'role' => $user->membership->role, ], ]; }), 'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array { return [ 'id' => $invitation->getKey(), 'email' => $invitation->email, 'role' => $invitation->role, ]; }), ], 'currencies' => array_map(function (Currency $currency): string { return $currency->getName(); }, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()), ]); } ); } } ================================================ FILE: app/Providers/RouteServiceProvider.php ================================================ app->isProduction()) { return Limit::none(); } return $request->user() ? Limit::perMinute(200)->by($request->user()->id) : Limit::perMinute(60)->by($request->ip()); }); $this->routes(function (): void { Route::middleware('health-check') ->group(function (): void { Route::get('health-check/up', [HealthCheckController::class, 'up']); Route::get('health-check/debug', [HealthCheckController::class, 'debug']); }); Route::middleware('api') ->prefix('api') ->name('api.') ->group(base_path('routes/api.php')); Route::middleware('web') ->group(base_path('routes/web.php')); }); } } ================================================ FILE: app/Providers/TelescopeServiceProvider.php ================================================ hideSensitiveRequestDetails(); Telescope::filter(function (IncomingEntry $entry): bool { if ($this->app->environment('local')) { return true; } return $entry->isReportableException() || $entry->isFailedRequest() || $entry->isFailedJob() || $entry->isScheduledTask() || $entry->hasMonitoredTag(); }); } /** * Prevent sensitive request details from being logged by Telescope. */ protected function hideSensitiveRequestDetails(): void { if ($this->app->environment('local')) { return; } Telescope::hideRequestParameters(['_token']); Telescope::hideRequestHeaders([ 'cookie', 'x-csrf-token', 'x-xsrf-token', ]); } /** * Register the Telescope gate. * * This gate determines who can access Telescope in non-local environments. */ protected function gate(): void { Gate::define('viewTelescope', function (User $user): bool { // Note: Telescope is only available in local environments, so this should not be relevant. return false; }); } } ================================================ FILE: app/Rules/ColorRule.php ================================================ isValid($value)) { $fail(__('validation.color')); return; } } } ================================================ FILE: app/Rules/CurrencyRule.php ================================================ getAvailableCurrencies(); if (array_key_exists($value, $currencies)) { return; } $fail(__('validation.currency')); } } ================================================ FILE: app/Service/ApiService.php ================================================ timeout(3) ->connectTimeout(2) ->post(self::API_URL.'/ping/version', [ 'version' => config('app.version'), 'build' => config('app.build'), 'url' => config('app.url'), ]); if ($response->status() === 200 && isset($response->json()['version']) && is_string($response->json()['version'])) { return $response->json()['version']; } else { Log::warning('Failed to check for update', [ 'status' => $response->status(), 'body' => $response->body(), ]); return null; } } catch (\Throwable $e) { Log::warning('Failed to check for update', [ 'message' => $e->getMessage(), ]); return null; } } public function telemetry(): bool { try { $response = Http::asJson() ->timeout(3) ->connectTimeout(2) ->post(self::API_URL.'/ping/telemetry', [ 'version' => config('app.version'), 'build' => config('app.build'), 'url' => config('app.url'), // telemetry data 'user_count' => User::count(), 'organization_count' => Organization::count(), 'audit_count' => Audit::count(), 'project_count' => Project::count(), 'project_member_count' => ProjectMember::count(), 'client_count' => Client::count(), 'task_count' => Task::count(), 'time_entry_count' => TimeEntry::count(), ]); if ($response->status() === 200) { return true; } else { Log::warning('Failed send telemetry data', [ 'status' => $response->status(), 'body' => $response->body(), ]); return false; } } catch (Exception $e) { Log::warning('Failed send telemetry data', [ 'message' => $e->getMessage(), ]); return false; } } } ================================================ FILE: app/Service/BillableRateService.php ================================================ where('billable', '=', true) ->where('member_id', '=', $projectMember->member_id) ->where('project_id', '=', $projectMember->project_id) ->update(['billable_rate' => $projectMember->billable_rate]); } public function updateTimeEntriesBillableRateForProject(Project $project): void { TimeEntry::query() ->where('billable', '=', true) ->where('organization_id', '=', $project->organization_id) ->whereBelongsTo($project, 'project') ->whereDoesntHave('member', function (Builder $query) use ($project): void { /** @var Builder $query */ $query->whereHas('projectMembers', function (Builder $query) use ($project): void { /** @var Builder $query */ $query->whereBelongsTo($project, 'project') ->whereNotNull('billable_rate'); }); }) ->update(['billable_rate' => $project->billable_rate]); } public function updateTimeEntriesBillableRateForMember(Member $member): void { TimeEntry::query() ->where('billable', '=', true) ->where('organization_id', '=', $member->organization_id) ->where('member_id', '=', $member->getKey()) ->whereDoesntHave('project', function (Builder $builder) use ($member): void { /** @var Builder $builder */ $builder->whereNotNull('billable_rate') ->orWhereHas('members', function (Builder $builder) use ($member): void { /** @var Builder $builder */ $builder->whereNotNull('billable_rate') ->where('member_id', '=', $member->getKey()); }); }) ->update(['billable_rate' => $member->billable_rate]); } public function updateTimeEntriesBillableRateForOrganization(Organization $organization): void { TimeEntry::query() ->where('billable', '=', true) ->where('organization_id', '=', $organization->getKey()) ->whereDoesntHave('member', function (Builder $builder): void { /** @var Builder $builder */ $builder->whereNotNull('billable_rate'); }) ->whereDoesntHave('project', function (Builder $builder): void { /** @var Builder $builder */ $builder->whereNotNull('billable_rate') ->orWhereHas('members', function (Builder $builder): void { /** @var Builder $builder */ $builder->whereNotNull('billable_rate') ->whereRaw('member_id = time_entries.member_id'); }); }) ->update(['billable_rate' => $organization->billable_rate]); } public function getBillableRateForTimeEntryWithGivenRelations(TimeEntry $timeEntry, ?ProjectMember $projectMember, ?Project $project, ?Member $member, ?Organization $organization): ?int { if (! $timeEntry->billable) { return null; } if ($projectMember !== null && $projectMember->billable_rate !== null) { return $projectMember->billable_rate; } if ($project !== null && $project->billable_rate !== null) { return $project->billable_rate; } if ($member !== null && $member->billable_rate !== null) { return $member->billable_rate; } if ($organization !== null && $organization->billable_rate !== null) { return $organization->billable_rate; } return null; } public function getBillableRateForTimeEntry(TimeEntry $timeEntry): ?int { if (! $timeEntry->billable) { return null; } if ($timeEntry->project_id !== null) { // Project member rate /** @var ProjectMember|null $projectMember */ $projectMember = ProjectMember::query() ->where('user_id', '=', $timeEntry->user_id) ->where('project_id', '=', $timeEntry->project_id) ->first(); if ($projectMember !== null && $projectMember->billable_rate !== null) { return $projectMember->billable_rate; } // Project rate /** @var Project|null $project */ $project = Project::find($timeEntry->project_id); if ($project !== null && $project->billable_rate !== null) { return $project->billable_rate; } } // Member rate /** @var Member|null $member */ $member = Member::query() ->where('user_id', '=', $timeEntry->user_id) ->where('organization_id', '=', $timeEntry->organization_id) ->first(); if ($member !== null && $member->billable_rate !== null) { return $member->billable_rate; } // Organization rate /** @var Organization|null $organization */ $organization = Organization::query() ->where('id', '=', $timeEntry->organization_id) ->first(); if ($organization !== null && $organization->billable_rate !== null) { return $organization->billable_rate; } return null; } } ================================================ FILE: app/Service/BillingContract.php ================================================ */ private const array COLORS = [ '#ef5350', '#ec407a', '#ab47bc', '#7e57c2', '#5c6bc0', '#42a5f5', '#29b6f6', '#26c6da', '#26a69a', '#66bb6a', '#9ccc65', '#d4e157', '#ffee58', '#ffca28', '#ffa726', '#ff7043', '#8d6e63', '#bdbdbd', '#78909c', ]; private const string VALID_REGEX = '/^#[0-9a-f]{6}$/'; public function isBuiltInColor(string $color): bool { return in_array($color, self::COLORS, true); } public function getRandomColor(?string $seed = null): string { if ($seed !== null) { srand(crc32($seed)); } return self::COLORS[array_rand(self::COLORS)]; } public function isValid(string $color): bool { return preg_match(self::VALID_REGEX, $color) === 1; } } ================================================ FILE: app/Service/CurrencyService.php ================================================ > */ private const array CURRENCIES = [ 'ALL' => [ 'symbol' => 'L', ], 'AFN' => [ 'symbol' => '؋', ], 'ARS' => [ 'symbol' => '$', ], 'AWG' => [ 'symbol' => 'ƒ', ], 'AUD' => [ 'symbol' => '$', ], 'AZN' => [ 'symbol' => '₼', ], 'BSD' => [ 'symbol' => '$', ], 'BBD' => [ 'symbol' => '$', ], 'BDT' => [ 'symbol' => '৳', ], 'BYR' => [ 'symbol' => 'Br', ], 'BZD' => [ 'symbol' => 'BZ$', ], 'BMD' => [ 'symbol' => '$', ], 'BOB' => [ 'symbol' => '$b', ], 'BAM' => [ 'symbol' => 'KM', ], 'BWP' => [ 'symbol' => 'P', ], 'BGN' => [ 'symbol' => 'лв', ], 'BRL' => [ 'symbol' => 'R$', ], 'BND' => [ 'symbol' => '$', ], 'KHR' => [ 'symbol' => '៛', ], 'CAD' => [ 'symbol' => '$', ], 'KYD' => [ 'symbol' => '$', ], 'CLP' => [ 'symbol' => '$', ], 'CNY' => [ 'symbol' => '¥', ], 'COP' => [ 'symbol' => '$', ], 'CRC' => [ 'symbol' => '₡', ], 'HRK' => [ 'symbol' => 'kn', ], 'CUP' => [ 'symbol' => '₱', ], 'CZK' => [ 'symbol' => 'Kč', ], 'DKK' => [ 'symbol' => 'kr', ], 'DOP' => [ 'symbol' => 'RD$', ], 'XCD' => [ 'symbol' => '$', ], 'EGP' => [ 'symbol' => '£', ], 'SVC' => [ 'symbol' => '$', ], 'EEK' => [ 'symbol' => 'kr', ], 'EUR' => [ 'symbol' => '€', ], 'FKP' => [ 'symbol' => '£', ], 'FJD' => [ 'symbol' => '$', ], 'GHC' => [ 'symbol' => '₵', ], 'GIP' => [ 'symbol' => '£', ], 'GTQ' => [ 'symbol' => 'Q', ], 'GGP' => [ 'symbol' => '£', ], 'GYD' => [ 'symbol' => '$', ], 'HNL' => [ 'symbol' => 'L', ], 'HKD' => [ 'symbol' => '$', ], 'HUF' => [ 'symbol' => 'Ft', ], 'ISK' => [ 'symbol' => 'kr', ], 'INR' => [ 'symbol' => '₹', ], 'IDR' => [ 'symbol' => 'Rp', ], 'IRR' => [ 'symbol' => '﷼', ], 'IMP' => [ 'symbol' => '£', ], 'ILS' => [ 'symbol' => '₪', ], 'JMD' => [ 'symbol' => 'J$', ], 'JPY' => [ 'symbol' => '¥', ], 'JEP' => [ 'symbol' => '£', ], 'KZT' => [ 'symbol' => 'лв', ], 'KPW' => [ 'symbol' => '₩', ], 'KRW' => [ 'symbol' => '₩', ], 'KGS' => [ 'symbol' => 'лв', ], 'LAK' => [ 'symbol' => '₭', ], 'LVL' => [ 'symbol' => 'Ls', ], 'LBP' => [ 'symbol' => '£', ], 'LRD' => [ 'symbol' => '$', ], 'LTL' => [ 'symbol' => 'Lt', ], 'MKD' => [ 'symbol' => 'ден', ], 'MYR' => [ 'symbol' => 'RM', ], 'MUR' => [ 'symbol' => '₨', ], 'MXN' => [ 'symbol' => '$', ], 'MNT' => [ 'symbol' => '₮', ], 'MZN' => [ 'symbol' => 'MT', ], 'NAD' => [ 'symbol' => '$', ], 'NPR' => [ 'symbol' => '₨', ], 'ANG' => [ 'symbol' => 'ƒ', ], 'NZD' => [ 'symbol' => '$', ], 'NIO' => [ 'symbol' => 'C$', ], 'NGN' => [ 'symbol' => '₦', ], 'NOK' => [ 'symbol' => 'kr', ], 'OMR' => [ 'symbol' => '﷼', ], 'PKR' => [ 'symbol' => '₨', ], 'PAB' => [ 'symbol' => 'B/.', ], 'PYG' => [ 'symbol' => 'Gs', ], 'PEN' => [ 'symbol' => 'S/.', ], 'PHP' => [ 'symbol' => '₱', ], 'PLN' => [ 'symbol' => 'zł', ], 'QAR' => [ 'symbol' => '﷼', ], 'RON' => [ 'symbol' => 'lei', ], 'RUB' => [ 'symbol' => '₽', ], 'SHP' => [ 'symbol' => '£', ], 'SAR' => [ 'symbol' => '﷼', ], 'RSD' => [ 'symbol' => 'Дин.', ], 'SCR' => [ 'symbol' => '₨', ], 'SGD' => [ 'symbol' => '$', ], 'SBD' => [ 'symbol' => '$', ], 'SOS' => [ 'symbol' => 'S', ], 'ZAR' => [ 'symbol' => 'R', ], 'LKR' => [ 'symbol' => '₨', ], 'SEK' => [ 'symbol' => 'kr', ], 'CHF' => [ 'symbol' => 'CHF', ], 'SRD' => [ 'symbol' => '$', ], 'SYP' => [ 'symbol' => '£', ], 'TWD' => [ 'symbol' => 'NT$', ], 'THB' => [ 'symbol' => '฿', ], 'TTD' => [ 'symbol' => 'TT$', ], 'TRY' => [ 'symbol' => '₺', ], 'TRL' => [ 'symbol' => '₤', ], 'TVD' => [ 'symbol' => '$', ], 'UAH' => [ 'symbol' => '₴', ], 'GBP' => [ 'symbol' => '£', ], 'UGX' => [ 'symbol' => 'USh', ], 'USD' => [ 'symbol' => '$', ], 'UYU' => [ 'symbol' => '$U', ], 'UZS' => [ 'symbol' => 'лв', ], 'VEF' => [ 'symbol' => 'Bs', ], 'VND' => [ 'symbol' => '₫', ], 'YER' => [ 'symbol' => '﷼', ], 'ZWD' => [ 'symbol' => 'Z$', ], ]; public function getCurrencySymbolForMoney(Money $money): string { return $this->getCurrencySymbol($money->getCurrency()->getCurrencyCode()); } public function getCurrencySymbol(string $currencyCode): string { if (isset(self::CURRENCIES[$currencyCode]['symbol'])) { return self::CURRENCIES[$currencyCode]['symbol']; } return $currencyCode; } public function getRandomCurrencyCode(): string { $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies(); $currencyCodes = array_keys($currencies); return $currencyCodes[array_rand($currencyCodes)]; } } ================================================ FILE: app/Service/DashboardService.php ================================================ 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++) { $date->addDay(); $result->push($date->format('Y-m-d')); } return $result; } /** * @return array{start: string, end: string, dates: array>} */ private function lastDaysSplitInWindows(int $days, CarbonTimeZone $timeZone, int $windows): array { $result = []; $windowSize = 24 / $windows; $end = Carbon::now($timeZone)->startOfDay()->addDay()->subHours(3)->utc()->toDateTimeString(); $start = Carbon::now($timeZone)->subDays($days)->startOfDay()->utc()->toDateTimeString(); $date = Carbon::now($timeZone)->startOfDay(); $dateUtc = Carbon::now($timeZone)->startOfDay()->utc(); for ($i = 0; $i < $days; $i++) { $dateString = $date->format('Y-m-d'); $tempDate = $dateUtc->copy(); $start = $tempDate->copy()->utc()->toDateTimeString(); $tempWindows = []; for ($j = 0; $j < $windows; $j++) { $tempWindow = $tempDate->toDateTimeString(); $tempWindows[] = $tempWindow; $tempDate->addHours($windowSize); } $result[$dateString] = $tempWindows; $date->subDay(); $dateUtc->subDay(); } return [ 'start' => $start, 'end' => $end, 'dates' => $result, ]; } /** * @return Collection */ private function daysOfThisWeek(CarbonTimeZone $timeZone, Weekday $startOfWeek): Collection { $result = new Collection; $date = Carbon::now($timeZone); $start = $date->startOfWeek($startOfWeek->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 === null || $value1 === null) { 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(), ]); } /** * @param Builder $builder * @return Builder */ private function constrainDateByCurrentWeek(Builder $builder, CarbonTimeZone $timeZone, Weekday $startOfWeek): Builder { return $builder->whereBetween('start', [ Carbon::now($timeZone)->startOfWeek($startOfWeek->carbonWeekDay())->utc(), Carbon::now($timeZone)->endOfWeek($startOfWeek->toEndOfWeek()->carbonWeekDay())->utc(), ]); } /** * Get the daily tracked hours for the user * First value: date * Second value: seconds * * @return array */ public function getDailyTrackedHours(User $user, Organization $organization, int $days): array { $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->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->getKey()) ->where('organization_id', '=', $organization->getKey()) ->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; } /** * Statistics for the current week starting at weekday of users preference * * @return array */ public function getWeeklyHistory(User $user, Organization $organization): array { $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->getKey()) ->where('organization_id', '=', $organization->getKey()) ->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; } public function totalWeeklyTime(User $user, Organization $organization): int { $timezone = $this->timezoneService->getTimezoneFromUser($user); $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start); $query = TimeEntry::query() ->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->where('user_id', '=', $user->getKey()) ->where('organization_id', '=', $organization->getKey()); $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); /** @var Collection $resultDb */ $resultDb = $query->get(); return (int) $resultDb->get(0)->aggregate; } public function totalWeeklyBillableTime(User $user, Organization $organization): int { $timezone = $this->timezoneService->getTimezoneFromUser($user); $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start); $query = TimeEntry::query() ->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->where('billable', '=', true) ->where('user_id', '=', $user->getKey()) ->where('organization_id', '=', $organization->getKey()); $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); /** @var Collection $resultDb */ $resultDb = $query->get(); return (int) $resultDb->get(0)->aggregate; } /** * @return array{value: int, currency: string} */ public function totalWeeklyBillableAmount(User $user, Organization $organization): array { $timezone = $this->timezoneService->getTimezoneFromUser($user); $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start); $query = TimeEntry::query() ->select(DB::raw(' round( sum( extract(epoch from (coalesce("end", now()) - start)) * (billable_rate::float/60/60) ) ) as aggregate')) ->where('billable', '=', true) ->whereNotNull('billable_rate') ->where('user_id', '=', $user->getKey()) ->where('organization_id', '=', $organization->getKey()); $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); /** @var Collection $resultDb */ $resultDb = $query->get(); return [ 'value' => (int) $resultDb->get(0)->aggregate, 'currency' => $organization->currency, ]; } /** * @return array */ public function weeklyProjectOverview(User $user, Organization $organization): array { $timezone = $this->timezoneService->getTimezoneFromUser($user); $query = TimeEntry::query() ->select(DB::raw('project_id, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->where('user_id', '=', $user->getKey()) ->where('organization_id', '=', $organization->getKey()) ->groupBy('project_id'); $query = $this->constrainDateByCurrentWeek($query, $timezone, $user->week_start); /** @var Collection $entries */ $entries = $query->get(); $projectIds = $entries->pluck('project_id')->whereNotNull()->all(); $projectsMap = Project::query() ->select(['id', 'name', 'color']) ->whereBelongsTo($organization, 'organization') ->whereIn('id', $projectIds) ->get() ->keyBy('id'); $response = []; $aggregateOther = 0; foreach ($entries as $entry) { $project = $projectsMap->get($entry->project_id); if ($project === null) { $aggregateOther += (int) $entry->aggregate; continue; } $response[] = [ 'value' => (int) $entry->aggregate, 'id' => $entry->project_id, 'name' => $project->name, 'color' => $project->color, ]; } if ($aggregateOther > 0 || count($response) === 0) { $response[] = [ 'value' => $aggregateOther, 'id' => null, 'name' => 'No project', 'color' => '#cccccc', ]; } return $response; } /** * Rhe 4 most recently active members of your team with member_id, name, description of the latest time entry, time_entry_id, task_id and a boolean status if the team member is currently working * * @return array */ public function latestTeamActivity(Organization $organization): array { $timeEntries = TimeEntry::query() ->select(DB::raw('distinct on (member_id) member_id, description, id, task_id, start, "end"')) ->whereBelongsTo($organization, 'organization') ->orderBy('member_id') ->orderBy('start', 'desc') // Note: limit here does not work because of the distinct on ->with([ 'member' => [ 'user', ], ]) ->get() ->sortByDesc('start') ->slice(0, 4); $response = []; foreach ($timeEntries as $timeEntry) { $response[] = [ 'member_id' => $timeEntry->member_id, 'name' => $timeEntry->member->user->name, 'description' => $timeEntry->description, 'time_entry_id' => $timeEntry->id, 'task_id' => $timeEntry->task_id, 'status' => $timeEntry->end === null, ]; } return $response; } /** * The 4 tasks with the most recent time entries * * @return array */ public function latestTasks(User $user, Organization $organization): array { $tasks = Task::query() ->where('organization_id', '=', $organization->getKey()) ->with([ 'project', ]) ->whereHas('timeEntries', function (Builder $builder) use ($user, $organization): void { /** @var Builder $builder */ $builder->where('user_id', '=', $user->getKey()) ->where('organization_id', '=', $organization->getKey()); }) ->orderByDesc( TimeEntry::select('start') ->whereColumn('task_id', 'tasks.id') ->orderBy('start', 'desc') ->limit(1) ) ->limit(4) ->get(); $response = []; foreach ($tasks as $task) { $response[] = [ 'id' => $task->id, 'name' => $task->name, 'project_name' => $task->project->name, 'project_id' => $task->project->id, ]; } return $response; } /** * The last 7 days with statistics for the time entries * * @return array }> */ public function lastSevenDays(User $user, Organization $organization): array { $timezone = $this->timezoneService->getTimezoneFromUser($user); $lastDaysSplitInWindows = $this->lastDaysSplitInWindows(7, $timezone, 8); $data = collect(DB::select(' SELECT time_ranges.start, EXTRACT(epoch FROM sum(LEAST(time_ranges."end", coalesce(time_entries."end", :now::timestamp)) - GREATEST(time_ranges.start, time_entries.start))) AS aggregate FROM ( SELECT time_range_starts.start AS start, time_range_starts.start + interval \'3 hours\' AS "end" FROM generate_series(:start_time_ranges::timestamp, :end_time_ranges::timestamp + interval \'3 hours\', interval \'3 hours\') as time_range_starts (start) ) time_ranges JOIN time_entries ON time_entries.start < time_ranges."end" AND coalesce(time_entries."end", :now::timestamp) > time_ranges.start WHERE time_entries.user_id = :user_id and time_entries.organization_id = :organization_id GROUP BY time_ranges.start ORDER BY time_ranges.start ', [ 'start_time_ranges' => $lastDaysSplitInWindows['start'], 'end_time_ranges' => $lastDaysSplitInWindows['end'], 'user_id' => $user->getKey(), 'organization_id' => $organization->getKey(), 'now' => Carbon::now()->toDateTimeString(), ]))->pluck('aggregate', 'start'); $response = []; foreach ($lastDaysSplitInWindows['dates'] as $date => $windows) { $history = []; $duration = 0; foreach ($windows as $window) { $value = (int) ($data->get($window, null) ?? 0); $history[] = $value; $duration += $value; } $response[] = [ 'date' => $date, 'duration' => $duration, 'history' => $history, ]; } return $response; } } ================================================ FILE: app/Service/DeletionService.php ================================================ userService = $userService; $this->memberService = $memberService; } public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void { if ($inTransaction) { DB::transaction(function () use ($organization): void { $this->deleteOrganization($organization, false); }); return; } Log::debug('Start deleting organization', [ 'organization_id' => $organization->getKey(), 'name' => $organization->name, 'owner_id' => $organization->user_id, ]); BeforeOrganizationDeletion::dispatch($organization); // Delete all organization invitations OrganizationInvitation::query()->whereBelongsTo($organization, 'organization')->delete(); // Delete all time entries TimeEntry::query()->whereBelongsTo($organization, 'organization')->delete(); // Delete all tags Tag::query()->whereBelongsTo($organization, 'organization')->delete(); // Delete all tasks Task::query()->whereBelongsTo($organization, 'organization')->delete(); // Delete all project members ProjectMember::query()->whereBelongsToOrganization($organization)->delete(); // Delete all projects Project::query()->whereBelongsTo($organization, 'organization')->delete(); // Delete all clients Client::query()->whereBelongsTo($organization, 'organization')->delete(); // Delete all reports Report::query()->whereBelongsTo($organization, 'organization')->delete(); // Reset the current organization $organization->owner() ->where('current_team_id', $organization->getKey()) ->update(['current_team_id' => null]); $organization->users() ->where('current_team_id', $organization->getKey()) ->update(['current_team_id' => null]); // Delete all members $users = $organization->users() ->with([ 'currentOrganization', ]) ->get(); $members = Member::query() ->whereBelongsTo($organization, 'organization') ->get(); foreach ($members as $member) { $member->delete(); } // Make sure all users have at least one organization and delete placeholders foreach ($users as $user) { /** @var User $user */ if ($ignoreUser !== null && $user->is($ignoreUser)) { continue; } if ($user->is_placeholder) { $user->delete(); } else { if ($user->current_team_id === $organization->getKey()) { $user->currentOrganization()->disassociate(); $user->save(); } $this->userService->makeSureUserHasAtLeastOneOrganization($user); $this->userService->makeSureUserHasCurrentOrganization($user); } } // Delete organization $organization->delete(); Log::debug('Finished deleting organization', [ 'organization_id' => $organization->getKey(), 'name' => $organization->name, 'owner_id' => $organization->user_id, ]); } /** * @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers */ public function deleteUser(User $user, bool $inTransaction = true): void { if ($inTransaction) { DB::transaction(function () use ($user): void { $this->deleteUser($user, false); }); return; } Log::debug('Start deleting user', [ 'id' => $user->getKey(), 'name' => $user->name, 'email' => $user->email, ]); $members = Member::query()->whereBelongsTo($user, 'user') ->with([ 'organization', 'user', ]) ->get(); foreach ($members as $member) { /** @var Member $member */ if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) { throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; } } /** @var Member $member */ foreach ($members as $member) { if ($member->role === Role::Owner->value) { $this->deleteOrganization($member->organization, false, $user); } else { $this->memberService->makeMemberToPlaceholder($member, false); } } $user->accessTokens()->delete(); $user->authCodes()->delete(); // Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last $user->deleteProfilePhoto(); $user->delete(); Log::debug('Finished deleting user', [ 'id' => $user->getKey(), 'name' => $user->name, 'email' => $user->email, ]); } } ================================================ FILE: app/Service/Dto/ReportPropertiesDto.php ================================================ |null */ public ?Collection $memberIds = null; public ?bool $billable = null; /** * @var Collection|null */ public ?Collection $clientIds = null; /** * @var Collection|null */ public ?Collection $projectIds = null; /** * @var Collection|null */ public ?Collection $tagIds = null; /** * @var Collection|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. * * @param array $arguments * @return CastsAttributes */ public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { private const array REQUIRED_PROPERTIES = [ 'group', 'subGroup', 'historyGroup', 'weekStart', 'timezone', 'start', 'end', 'active', 'memberIds', 'billable', 'clientIds', 'projectIds', 'tagIds', 'taskIds', ]; public function get(Model $model, string $key, mixed $value, array $attributes): ReportPropertiesDto { if (! is_string($value)) { throw new \InvalidArgumentException('The given value is not a string'); } $data = json_decode($value, false); if ($data === null) { throw new \InvalidArgumentException('The given value is not a JSON string'); } foreach (self::REQUIRED_PROPERTIES as $property) { if (! property_exists($data, $property)) { throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"'); } } $dto = new ReportPropertiesDto; $dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null; $dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null; $dto->active = $data->active; $dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null; $dto->billable = $data->billable; $dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null; $dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null; $dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null; $dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null; $dto->group = TimeEntryAggregationType::from($data->group); $dto->subGroup = TimeEntryAggregationType::from($data->subGroup); $dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup); $dto->weekStart = Weekday::from($data->weekStart); $dto->timezone = $data->timezone; // Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB $dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null; // Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB $dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null; return $dto; } public function set(Model $model, string $key, mixed $value, array $attributes): string { if (! ($value instanceof ReportPropertiesDto)) { throw new \InvalidArgumentException('The given value is not an instance of ReportPropertiesDto'); } $data = (object) [ 'end' => $value->end->toIso8601ZuluString(), 'start' => $value->start->toIso8601ZuluString(), 'active' => $value->active, 'memberIds' => $value->memberIds?->toArray(), 'billable' => $value->billable, 'clientIds' => $value->clientIds?->toArray(), 'projectIds' => $value->projectIds?->toArray(), 'tagIds' => $value->tagIds?->toArray(), 'taskIds' => $value->taskIds?->toArray(), 'group' => $value->group->value, 'subGroup' => $value->subGroup->value, 'historyGroup' => $value->historyGroup->value, 'weekStart' => $value->weekStart->value, 'timezone' => $value->timezone, 'roundingType' => $value->roundingType?->value, 'roundingMinutes' => $value->roundingMinutes, ]; $jsonString = json_encode($data); if ($jsonString === false) { throw new \InvalidArgumentException('Could not encode the given data to a JSON string'); } return $jsonString; } }; } /** * @param array $ids * @return Collection */ public static function idArrayToCollection(array $ids): Collection { $collection = new Collection; foreach ($ids as $id) { if (! is_string($id)) { throw new \InvalidArgumentException('The given ID is not a string'); } if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) { throw new \InvalidArgumentException('The given ID is not a valid UUID'); } $collection->push($id); } return $collection; } /** * @param array|null $memberIds */ public function setMemberIds(?array $memberIds): void { $this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null; } /** * @param array|null $clientIds */ public function setClientIds(?array $clientIds): void { $this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null; } /** * @param array|null $projectIds */ public function setProjectIds(?array $projectIds): void { $this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null; } /** * @param array|null $tagIds */ public function setTagIds(?array $tagIds): void { $this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null; } /** * @param array|null $taskIds */ public function setTaskIds(?array $taskIds): void { $this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null; } } ================================================ FILE: app/Service/Export/ExportException.php ================================================ $organization->getKey(), 'export_id' => $exportId, ]); // Organizations try { $writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+'); $writer->setDelimiter(','); $writer->setEnclosure('"'); $writer->setEscape(''); $writer->insertOne([ 'id', 'name', 'billable_rate', 'currency', 'created_at', 'updated_at', ]); $writer->insertOne([ $organization->id, $organization->name, $organization->billable_rate ?? '', $organization->currency, $organization->created_at?->toIso8601ZuluString() ?? '', $organization->updated_at?->toIso8601ZuluString() ?? '', ]); // Organization invitations $writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+'); $writer->setDelimiter(','); $writer->setEnclosure('"'); $writer->setEscape(''); $writer->insertOne([ 'id', 'email', 'organization_id', 'role', 'created_at', 'updated_at', ]); OrganizationInvitation::query() ->whereBelongsTo($organization, 'organization') ->chunk(1000, function (Collection $organizationInvitations) use (&$writer): void { $organizationInvitations->each(function (OrganizationInvitation $organizationInvitation) use (&$writer): void { $writer->insertOne([ $organizationInvitation->id, $organizationInvitation->email, $organizationInvitation->organization_id, $organizationInvitation->role, $organizationInvitation->created_at?->toIso8601ZuluString() ?? '', $organizationInvitation->updated_at?->toIso8601ZuluString() ?? '', ]); }); }); // Time entries $writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+'); $writer->setDelimiter(','); $writer->setEnclosure('"'); $writer->setEscape(''); $writer->insertOne([ 'id', 'description', 'start', 'end', 'billable_rate', 'billable', 'member_id', 'user_id', 'organization_id', 'client_id', 'project_id', 'task_id', 'tags', 'is_imported', 'still_active_email_sent_at', 'created_at', 'updated_at', ]); TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->chunk(1000, function (Collection $timeEntries) use (&$writer): void { $timeEntries->each(function (TimeEntry $timeEntry) use (&$writer): void { $tags = json_encode($timeEntry->tags); $writer->insertOne([ $timeEntry->id, $timeEntry->description, $timeEntry->start->toIso8601ZuluString(), $timeEntry->end?->toIso8601ZuluString() ?? '', $timeEntry->billable_rate ?? '', $timeEntry->billable ? 'true' : 'false', $timeEntry->member_id, $timeEntry->user_id, $timeEntry->organization_id, $timeEntry->client_id ?? '', $timeEntry->project_id ?? '', $timeEntry->task_id ?? '', $tags === false ? '' : $tags, $timeEntry->is_imported ? 'true' : 'false', $timeEntry->still_active_email_sent_at?->toIso8601ZuluString() ?? '', $timeEntry->created_at?->toIso8601ZuluString() ?? '', $timeEntry->updated_at?->toIso8601ZuluString() ?? '', ]); }); }); // Clients $writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+'); $writer->setDelimiter(','); $writer->setEnclosure('"'); $writer->setEscape(''); $writer->insertOne([ 'id', 'name', 'organization_id', 'archived_at', 'created_at', 'updated_at', ]); Client::query() ->whereBelongsTo($organization, 'organization') ->chunk(1000, function (Collection $clients) use (&$writer): void { $clients->each(function (Client $client) use (&$writer): void { $writer->insertOne([ $client->id, $client->name, $client->organization_id, $client->archived_at?->toIso8601ZuluString() ?? '', $client->created_at?->toIso8601ZuluString() ?? '', $client->updated_at?->toIso8601ZuluString() ?? '', ]); }); }); // Projects $writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+'); $writer->setDelimiter(','); $writer->setEnclosure('"'); $writer->setEscape(''); $writer->insertOne([ 'id', 'name', 'color', 'billable_rate', 'is_public', 'client_id', 'organization_id', 'is_billable', 'archived_at', 'created_at', 'updated_at', ]); Project::query() ->whereBelongsTo($organization, 'organization') ->chunk(1000, function (Collection $projects) use (&$writer): void { $projects->each(function (Project $project) use (&$writer): void { $writer->insertOne([ $project->id, $project->name, $project->color, $project->billable_rate ?? '', $project->is_public ? 'true' : 'false', $project->client_id ?? '', $project->organization_id, $project->is_billable ? 'true' : 'false', $project->archived_at?->toIso8601ZuluString() ?? '', $project->created_at?->toIso8601ZuluString() ?? '', $project->updated_at?->toIso8601ZuluString() ?? '', ]); }); }); // Project members $writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+'); $writer->setDelimiter(','); $writer->setEnclosure('"'); $writer->setEscape(''); $writer->insertOne([ 'id', 'billable_rate', 'project_id', 'user_id', 'member_id', 'created_at', 'updated_at', ]); ProjectMember::query() ->whereBelongsToOrganization($organization) ->chunk(1000, function (Collection $projectMembers) use (&$writer): void { $projectMembers->each(function (ProjectMember $projectMember) use (&$writer): void { $writer->insertOne([ $projectMember->id, $projectMember->billable_rate ?? '', $projectMember->project_id, $projectMember->user_id, $projectMember->member_id, $projectMember->created_at?->toIso8601ZuluString() ?? '', $projectMember->updated_at?->toIso8601ZuluString() ?? '', ]); }); }); // Members $writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+'); $writer->setDelimiter(','); $writer->setEnclosure('"'); $writer->setEscape(''); $writer->insertOne([ 'id', 'user_id', 'name', 'email', 'organization_id', 'billable_rate', 'role', 'created_at', 'updated_at', ]); Member::query() ->whereBelongsTo($organization, 'organization') ->with([ 'user', ]) ->chunk(1000, function (Collection $members) use (&$writer): void { $members->each(function (Member $member) use (&$writer): void { $writer->insertOne([ $member->id, $member->user_id, $member->user->name, $member->user->email, $member->organization_id, $member->billable_rate ?? '', $member->role, $member->created_at?->toIso8601ZuluString() ?? '', $member->updated_at?->toIso8601ZuluString() ?? '', ]); }); }); // Tasks $writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+'); $writer->setDelimiter(','); $writer->setEnclosure('"'); $writer->setEscape(''); $writer->insertOne([ 'id', 'name', 'project_id', 'organization_id', 'done_at', 'created_at', 'updated_at', ]); Task::query() ->whereBelongsTo($organization, 'organization') ->chunk(1000, function (Collection $tasks) use (&$writer): void { $tasks->each(function (Task $task) use (&$writer): void { $writer->insertOne([ $task->id, $task->name, $task->project_id, $task->organization_id, $task->done_at?->toIso8601ZuluString() ?? '', $task->created_at?->toIso8601ZuluString() ?? '', $task->updated_at?->toIso8601ZuluString() ?? '', ]); }); }); // Tags $writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+'); $writer->setDelimiter(','); $writer->setEnclosure('"'); $writer->setEscape(''); $writer->insertOne([ 'id', 'name', 'organization_id', 'created_at', 'updated_at', ]); Tag::query() ->whereBelongsTo($organization, 'organization') ->chunk(1000, function (Collection $tags) use (&$writer): void { $tags->each(function (Tag $tag) use (&$writer): void { $writer->insertOne([ $tag->id, $tag->name, $tag->organization_id, $tag->created_at?->toIso8601ZuluString() ?? '', $tag->updated_at?->toIso8601ZuluString() ?? '', ]); }); }); // Meta data file $metaData = (object) [ 'id' => $exportId, 'version' => self::VERSION, 'organizations' => [$organization->getKey()], 'exported_at' => $timeStamp->toIso8601ZuluString(), ]; file_put_contents($temporaryDirectory->path('meta.json'), json_encode($metaData)); // Create ZIP file $temporaryDirectoryZip = TemporaryDirectory::make(); $zip = new ZipArchive; if ($zip->open($temporaryDirectoryZip->path('export.zip'), ZipArchive::CREATE) !== true) { throw new Exception('Cannot create ZIP file'); } $zip->addFile($temporaryDirectory->path('organizations.csv'), 'organizations.csv'); $zip->addFile($temporaryDirectory->path('organization_invitations.csv'), 'organization_invitations.csv'); $zip->addFile($temporaryDirectory->path('time_entries.csv'), 'time_entries.csv'); $zip->addFile($temporaryDirectory->path('clients.csv'), 'clients.csv'); $zip->addFile($temporaryDirectory->path('projects.csv'), 'projects.csv'); $zip->addFile($temporaryDirectory->path('project_members.csv'), 'project_members.csv'); $zip->addFile($temporaryDirectory->path('members.csv'), 'members.csv'); $zip->addFile($temporaryDirectory->path('tasks.csv'), 'tasks.csv'); $zip->addFile($temporaryDirectory->path('tags.csv'), 'tags.csv'); $zip->addFile($temporaryDirectory->path('meta.json'), 'meta.json'); $zip->close(); // Upload ZIP file to private storage $filename = 'export_'.$organization->getKey().'_'.$timeStamp->format('Y-m-d_H-i-s').'_'.$exportId.'.zip'; Storage::disk(config('filesystems.private'))->putFileAs( 'exports', new File($temporaryDirectoryZip->path('export.zip')), $filename ); // Delete temp files $temporaryDirectoryZip->delete(); $temporaryDirectory->delete(); Log::debug('Finished exporting organization', [ 'organization_id' => $organization->getKey(), 'export_id' => $exportId, ]); return 'exports/'.$filename; } catch (UnavailableStream|CannotInsertRecord|Exception|LeagueCsvException $exception) { report($exception); throw new ExportException; } } } ================================================ FILE: app/Service/Import/ImportDatabaseHelper.php ================================================ */ private string $model; /** * @var string[] */ private array $identifiers; /** * @var array|null */ private ?array $mapIdentifierToKey = null; /** * @var array|null */ private ?array $mapKeyToModel = null; /** * @var array|null */ private ?array $mapIdentifierToModel = null; /** * @var array */ private array $mapExternalIdentifierToInternalIdentifier = []; private bool $attachToExisting; private ?Closure $queryModifier; private ?Closure $afterCreate; private int $createdCount; /** * @var array> */ private array $validate; private ?Closure $beforeSave; /** * @param class-string $model * @param array $identifiers * @param array> $validate */ public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null, array $validate = [], ?Closure $beforeSave = null) { $this->model = $model; $this->identifiers = $identifiers; $this->attachToExisting = $attachToExisting; $this->queryModifier = $queryModifier; $this->afterCreate = $afterCreate; $this->createdCount = 0; $this->validate = $validate; $this->beforeSave = $beforeSave; } /** * @return Builder */ private function getModelInstance(): Builder { return (new $this->model)->query(); } /** * @param array $identifierData * @param array $createValues */ private function createEntity(array $identifierData, array $createValues, ?string $externalIdentifier): string { $data = array_merge($identifierData, $createValues); $validator = Validator::make($data, $this->validate); if ($validator->fails()) { throw new ImportException('Invalid data: '.implode(', ', $validator->errors()->all())); } /** @var TModel $model */ $model = new $this->model; foreach ($data as $key => $value) { $model->{$key} = $value; } if ($this->beforeSave !== null) { ($this->beforeSave)($model); } if (method_exists($model, 'disableAuditing')) { $model->disableAuditing(); } $model->save(); if ($this->afterCreate !== null) { ($this->afterCreate)($model); } $hash = $this->getHash($identifierData); $this->mapIdentifierToKey[$hash] = $model->getKey(); $this->createdCount++; if ($externalIdentifier !== null) { $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash; } return $model->getKey(); } /** * @param array $data */ private function getHash(array $data): string { $jsonData = json_encode($data); if ($jsonData === false) { throw new \RuntimeException('Failed to encode data to JSON'); } return md5($jsonData); } /** * @param array $identifierData * @param array $createValues * * @throws ImportException */ public function getKey(array $identifierData, array $createValues = [], ?string $externalIdentifier = null): string { $this->checkMap(); $this->validateIdentifierData($identifierData); $hash = $this->getHash($identifierData); if ($this->attachToExisting) { $key = $this->mapIdentifierToKey[$hash] ?? null; if ($key !== null) { if ($externalIdentifier !== null) { $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash; } return $key; } return $this->createEntity($identifierData, $createValues, $externalIdentifier); } else { throw new \RuntimeException('Not implemented'); } } /** * @return TModel */ public function getModelById(string $id): ?Model { if ($this->mapKeyToModel === null) { $this->mapKeyToModel = []; } if (isset($this->mapKeyToModel[$id])) { return $this->mapKeyToModel[$id]; } /** @var TModel|null $model */ $model = $this->getModelInstance()->find($id); if ($model !== null) { $this->mapKeyToModel[$id] = $model; } return $model; } /** * @return array */ public function getCachedModels(): array { if ($this->mapKeyToModel === null) { return []; } return array_values($this->mapKeyToModel); } /** * @param array $identifierData * @return TModel|null */ public function getModel(array $identifierData): ?Model { if ($this->mapIdentifierToModel === null) { $this->mapIdentifierToModel = []; } $hash = $this->getHash($identifierData); if (isset($this->mapIdentifierToModel[$hash])) { return $this->mapIdentifierToModel[$hash]; } $model = $this->getModelInstance()->where($identifierData)->first(); if ($model !== null) { $this->mapIdentifierToModel[$hash] = $model; } return $model; } /** * @param array $identifierData * * @throws ImportException */ private function validateIdentifierData(array $identifierData): void { if (array_keys($identifierData) !== $this->identifiers) { throw new ImportException('Invalid identifier data'); } } public function getKeyByExternalIdentifier(string $externalIdentifier): ?string { $hash = $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] ?? null; if ($hash === null) { return null; } return $this->mapIdentifierToKey[$hash] ?? null; } /** * @return array */ public function getExternalIds(): array { // Note: Otherwise the external ids are integers return array_map(fn ($value) => (string) $value, array_keys($this->mapExternalIdentifierToInternalIdentifier)); } private function checkMap(): void { if ($this->mapIdentifierToKey === null) { $select = $this->identifiers; $select[] = (new $this->model)->getKeyName(); $builder = $this->getModelInstance(); if ($this->queryModifier !== null) { $builder = ($this->queryModifier)($builder); } $databaseEntries = $builder->select($select) ->get(); $this->mapIdentifierToKey = []; foreach ($databaseEntries as $databaseEntry) { $identifierData = []; foreach ($this->identifiers as $identifier) { $identifierData[$identifier] = $databaseEntry->{$identifier}; } $hash = $this->getHash($identifierData); $this->mapIdentifierToKey[$hash] = $databaseEntry->getKey(); } } } public function getCreatedCount(): int { return $this->createdCount; } } ================================================ FILE: app/Service/Import/ImportService.php ================================================ getImporter($importerType); $importer->init($organization); Storage::disk(config('filesystems.default')) ->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data); $lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1); if ($lock->get()) { try { DB::transaction(function () use (&$importer, &$data, &$timezone): void { $importer->importData($data, $timezone); }); } finally { $lock->release(); } } else { throw new ImportException('Import is already in progress'); } return $importer->getReport(); } } ================================================ FILE: app/Service/Import/Importers/ClockifyProjectsImporter.php ================================================ setHeaderOffset(0); $reader->setDelimiter(','); $header = $reader->getHeader(); $this->validateHeader($header); $billableRateKey = $this->getBillableRateKey($header); $records = $reader->getRecords(); foreach ($records as $record) { $clientId = null; if ($record['Client'] !== '') { $clientId = $this->clientImportHelper->getKey([ 'name' => $record['Client'], 'organization_id' => $this->organization->id, ]); } $projectId = null; if ($record['Project'] !== '') { $projectId = $this->projectImportHelper->getKey([ 'name' => $record['Project'], 'client_id' => $clientId, 'organization_id' => $this->organization->id, ], [ 'color' => $this->colorService->getRandomColor(), 'is_billable' => $record['Billability'] === 'Yes', 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null, ]); } if ($record['Task'] !== '') { $tasks = explode(', ', $record['Task']); foreach ($tasks as $task) { $this->taskImportHelper->getKey([ 'name' => $task, 'project_id' => $projectId, 'organization_id' => $this->organization->id, ]); } } } } catch (ImportException $exception) { throw $exception; } catch (CsvException $exception) { throw new ImportException('Invalid CSV data'); } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); } } /** * @param array $header * * @throws ImportException */ private function validateHeader(array $header): void { $requiredFields = [ 'Project', 'Client', 'Status', 'Visibility', 'Billability', 'Task', ]; foreach ($requiredFields as $requiredField) { if (! in_array($requiredField, $header, true)) { throw new ImportException('Invalid CSV header, missing field: '.$requiredField); } } } /** * @param array $header */ private function getBillableRateKey(array $header): ?string { $billableRateKey = null; foreach ($header as $value) { if (Str::startsWith($value, 'Billable Rate (')) { $billableRateKey = $value; break; } } return $billableRateKey; } #[\Override] public function getName(): string { return __('importer.clockify_projects.name'); } #[\Override] public function getDescription(): string { return __('importer.clockify_projects.description'); } } ================================================ FILE: app/Service/Import/Importers/ClockifyTimeEntriesImporter.php ================================================ * * @throws ImportException */ private function getTags(string $tags): array { if (Str::trim($tags) === '') { return []; } $tagsParsed = explode(', ', $tags); $tagIds = []; foreach ($tagsParsed as $tagParsed) { $tagId = $this->tagImportHelper->getKey([ 'name' => $tagParsed, 'organization_id' => $this->organization->id, ]); $tagIds[] = $tagId; } return $tagIds; } /** * @throws ImportException */ #[\Override] public function importData(string $data, string $timezone): void { try { $reader = Reader::createFromString($data); $reader->setHeaderOffset(0); $reader->setDelimiter(','); $reader->setEnclosure('"'); $reader->setEscape(''); $header = $reader->getHeader(); $this->validateHeader($header); $records = $reader->getRecords(); foreach ($records as $record) { $userId = $this->userImportHelper->getKey([ 'email' => $record['Email'], ], [ 'name' => $record['User'], 'timezone' => 'UTC', 'is_placeholder' => true, ]); $memberId = $this->memberImportHelper->getKey([ 'user_id' => $userId, 'organization_id' => $this->organization->getKey(), ], [ 'role' => Role::Placeholder->value, ]); $member = $this->memberImportHelper->getModelById($memberId); $clientId = null; if ($record['Client'] !== '') { $clientId = $this->clientImportHelper->getKey([ 'name' => $record['Client'], 'organization_id' => $this->organization->id, ]); } $projectId = null; $project = null; $projectMember = null; if ($record['Project'] !== '') { $projectId = $this->projectImportHelper->getKey([ 'name' => $record['Project'], 'client_id' => $clientId, 'organization_id' => $this->organization->id, ], [ 'color' => $this->colorService->getRandomColor(), 'is_billable' => false, ]); $project = $this->projectImportHelper->getModelById($projectId); $projectMember = $this->projectMemberImportHelper->getModel([ 'project_id' => $projectId, 'member_id' => $memberId, ]); } $taskId = null; if ($record['Task'] !== '') { $taskId = $this->taskImportHelper->getKey([ 'name' => $record['Task'], 'project_id' => $projectId, 'organization_id' => $this->organization->id, ]); $this->taskImportHelper->getModelById($taskId); } $timeEntry = new TimeEntry; $timeEntry->disableAuditing(); $timeEntry->user_id = $userId; $timeEntry->member_id = $memberId; $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->client_id = $clientId; $timeEntry->organization_id = $this->organization->id; if (strlen($record['Description']) > 5000) { throw new ImportException('Time entry description is too long'); } $timeEntry->description = $record['Description']; if (! in_array($record['Billable'], ['Yes', 'No'], true)) { throw new ImportException('Invalid billable value'); } $timeEntry->billable = $record['Billable'] === 'Yes'; $timeEntry->tags = $this->getTags($record['Tags']); $timeEntry->is_imported = true; // Start $start = null; try { $startDateStr = $record['Start Date']; $startTimeStr = $record['Start Time']; $startStr = $startDateStr.' '.$startTimeStr; $matches = []; $checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $startStr, $matches); if ($checkResult === 1) { if ((int) $matches[1] > 12) { throw new ImportException('Start date ("'.$startDateStr.'") is invalid, please select the correct date format before exporting from Clockify'); } if ($matches[6] === '') { $start = Carbon::createFromFormat('m/d/Y h:i A', $startStr, $timezone); } else { $start = Carbon::createFromFormat('m/d/Y H:i:s A', $startStr, $timezone); } } } catch (InvalidFormatException) { throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid'); } if ($start === null) { throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid'); } $timeEntry->start = $start->utc(); // End $end = null; try { $endDateStr = $record['End Date']; $endTimeStr = $record['End Time']; $endStr = $endDateStr.' '.$endTimeStr; $matches = []; $checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $endStr, $matches); if ($checkResult === 1) { if ((int) $matches[1] > 12) { throw new ImportException('Start date ("'.$endDateStr.'") is invalid, please select the correct date format before exporting from Clockify'); } if ($matches[6] === '') { $end = Carbon::createFromFormat('m/d/Y h:i A', $endStr, $timezone); } else { $end = Carbon::createFromFormat('m/d/Y H:i:s A', $endStr, $timezone); } } } catch (InvalidFormatException) { throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid'); } if ($end === null) { throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid'); } $timeEntry->end = $end->utc(); $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry, $projectMember, $project, $member, $this->organization ); $timeEntry->save(); $this->timeEntriesCreated++; } foreach ($this->projectImportHelper->getCachedModels() as $usedProject) { RecalculateSpentTimeForProject::dispatch($usedProject); } foreach ($this->taskImportHelper->getCachedModels() as $usedTask) { RecalculateSpentTimeForTask::dispatch($usedTask); } } catch (ImportException $exception) { throw $exception; } catch (CsvException $exception) { throw new ImportException('Invalid CSV data'); } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); } } /** * @param array $header * * @throws ImportException */ private function validateHeader(array $header): void { $requiredFields = [ 'Project', 'Client', 'Description', 'Task', 'User', 'Group', 'Email', 'Tags', 'Billable', 'Start Date', 'Start Time', 'End Date', 'End Time', ]; foreach ($requiredFields as $requiredField) { if (! in_array($requiredField, $header, true)) { throw new ImportException('Invalid CSV header, missing field: '.$requiredField); } } } #[\Override] public function getName(): string { return __('importer.clockify_time_entries.name'); } #[\Override] public function getDescription(): string { return __('importer.clockify_time_entries.description'); } } ================================================ FILE: app/Service/Import/Importers/DefaultImporter.php ================================================ */ protected ImportDatabaseHelper $userImportHelper; /** * @var ImportDatabaseHelper */ protected ImportDatabaseHelper $memberImportHelper; /** * @var ImportDatabaseHelper */ protected ImportDatabaseHelper $projectImportHelper; /** * @var ImportDatabaseHelper */ protected ImportDatabaseHelper $tagImportHelper; /** * @var ImportDatabaseHelper */ protected ImportDatabaseHelper $clientImportHelper; /** * @var ImportDatabaseHelper */ protected ImportDatabaseHelper $taskImportHelper; protected int $timeEntriesCreated; protected ColorService $colorService; protected TimezoneService $timezoneService; /** * @var ImportDatabaseHelper */ protected ImportDatabaseHelper $projectMemberImportHelper; /** * @var ImportDatabaseHelper */ protected ImportDatabaseHelper $organizationInvitationsImportHelper; protected BillableRateService $billableRateService; public function init(Organization $organization): void { $this->organization = $organization; $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->belongsToOrganization($this->organization); }, null, validate: [ 'name' => [ 'required', 'max:255', ], 'timezone' => [ 'required', 'timezone:all', ], ]); $this->memberImportHelper = new ImportDatabaseHelper(Member::class, ['user_id', 'organization_id'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); }, null, validate: [ 'role' => [ 'required', 'string', 'in:placeholder', ], ]); $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->where('organization_id', $this->organization->id); }, validate: [ 'name' => [ 'required', 'max:255', ], 'is_billable' => [ 'required', 'boolean', ], 'billable_rate' => [ 'nullable', 'integer', 'max:2147483647', ], 'client_id' => [ 'nullable', 'string', 'uuid', ], ], beforeSave: function (Project $project): void { if ($project->billable_rate === 0) { $project->billable_rate = null; } }); $this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsToOrganization($this->organization); }, validate: [ 'billable_rate' => [ 'nullable', 'integer', 'max:2147483647', ], ], beforeSave: function (ProjectMember $projectMember): void { if ($projectMember->billable_rate === 0) { $projectMember->billable_rate = null; } }); $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->where('organization_id', $this->organization->id); }, validate: [ 'name' => [ 'required', 'max:255', ], ]); $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->where('organization_id', $this->organization->id); }, validate: [ 'name' => [ 'required', 'max:255', ], ]); $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->where('organization_id', $this->organization->id); }, validate: [ 'name' => [ 'required', 'max:500', ], ]); $this->organizationInvitationsImportHelper = new ImportDatabaseHelper(OrganizationInvitation::class, ['email', 'organization_id'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->where('organization_id', $this->organization->id); }, validate: [ 'email' => [ 'required', 'email', 'max:255', ], ]); $this->timeEntriesCreated = 0; $this->colorService = app(ColorService::class); $this->timezoneService = app(TimezoneService::class); $this->billableRateService = app(BillableRateService::class); } #[\Override] public function getReport(): ReportDto { return new ReportDto( clientsCreated: $this->clientImportHelper->getCreatedCount(), projectsCreated: $this->projectImportHelper->getCreatedCount(), tasksCreated: $this->taskImportHelper->getCreatedCount(), timeEntriesCreated: $this->timeEntriesCreated, tagsCreated: $this->tagImportHelper->getCreatedCount(), usersCreated: $this->userImportHelper->getCreatedCount(), ); } } ================================================ FILE: app/Service/Import/Importers/GenericProjectsImporter.php ================================================ */ private const array REQUIRED_FIELDS = [ 'name', ]; /** * @throws ImportException */ #[Override] public function importData(string $data, string $timezone): void { try { $reader = Reader::createFromString($data); $reader->setHeaderOffset(0); $reader->setDelimiter(','); $reader->setEnclosure('"'); $reader->setEscape(''); $header = $reader->getHeader(); $this->validateHeader($header); $records = $reader->getRecords(); foreach ($records as $record) { $clientId = null; if (isset($record['client']) && $record['client'] !== '') { $clientId = $this->clientImportHelper->getKey([ 'name' => $record['client'], 'organization_id' => $this->organization->id, ]); } if ($record['name'] !== '') { $archivedAt = null; if (isset($record['archived_at']) && $record['archived_at'] !== '') { try { $archivedAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['archived_at'], 'UTC'); } catch (InvalidFormatException) { throw new ImportException('Value of archived_at ("'.$record['archived_at'].'") is invalid'); } } $this->projectImportHelper->getKey([ 'name' => $record['name'], 'client_id' => $clientId, 'organization_id' => $this->organization->id, ], [ 'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(), 'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null, 'is_public' => isset($record['is_public']) && $record['is_public'] === 'true', 'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true', 'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null, 'archived_at' => $archivedAt, ]); } } } catch (ImportException $exception) { throw $exception; } catch (CsvException $exception) { throw new ImportException('Invalid CSV data'); } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); } } /** * @param array $header * * @throws ImportException */ private function validateHeader(array $header): void { foreach (self::REQUIRED_FIELDS as $requiredField) { if (! in_array($requiredField, $header, true)) { throw new ImportException('Invalid CSV header, missing field: '.$requiredField); } } } #[Override] public function getName(): string { return __('importer.generic_projects.name'); } #[Override] public function getDescription(): string { return __('importer.generic_projects.description'); } } ================================================ FILE: app/Service/Import/Importers/GenericTimeEntriesImporter.php ================================================ */ private const array REQUIRED_FIELDS = [ 'description', 'billable', 'client', 'project', 'tags', 'start', 'end', 'task', 'user_name', 'user_email', ]; /** * @return array * * @throws ImportException */ private function getTags(string $tags): array { if (Str::trim($tags) === '') { return []; } $tagsParsed = explode(',', $tags); $tagIds = []; foreach ($tagsParsed as $tagParsed) { $tagId = $this->tagImportHelper->getKey([ 'name' => Str::trim($tagParsed), 'organization_id' => $this->organization->id, ]); $tagIds[] = $tagId; } return $tagIds; } /** * @throws ImportException */ #[\Override] public function importData(string $data, string $timezone): void { try { $reader = Reader::createFromString($data); $reader->setHeaderOffset(0); $reader->setDelimiter(','); $reader->setEnclosure('"'); $reader->setEscape(''); $header = $reader->getHeader(); $this->validateHeader($header); $records = $reader->getRecords(); foreach ($records as $record) { $userId = $this->userImportHelper->getKey([ 'email' => $record['user_email'], ], [ 'name' => $record['user_name'], 'timezone' => 'UTC', 'is_placeholder' => true, ]); $memberId = $this->memberImportHelper->getKey([ 'user_id' => $userId, 'organization_id' => $this->organization->getKey(), ], [ 'role' => Role::Placeholder->value, ]); $member = $this->memberImportHelper->getModelById($memberId); $clientId = null; if ($record['client'] !== '') { $clientId = $this->clientImportHelper->getKey([ 'name' => $record['client'], 'organization_id' => $this->organization->id, ]); } $projectId = null; $project = null; $projectMember = null; if ($record['project'] !== '') { $projectId = $this->projectImportHelper->getKey([ 'name' => $record['project'], 'client_id' => $clientId, 'organization_id' => $this->organization->id, ], [ 'is_billable' => false, 'color' => $this->colorService->getRandomColor(), ]); $project = $this->projectImportHelper->getModelById($projectId); $projectMember = $this->projectMemberImportHelper->getModel([ 'project_id' => $projectId, 'member_id' => $memberId, ]); } $taskId = null; if ($record['task'] !== '') { $taskId = $this->taskImportHelper->getKey([ 'name' => $record['task'], 'project_id' => $projectId, 'organization_id' => $this->organization->id, ]); $this->taskImportHelper->getModelById($taskId); } $timeEntry = new TimeEntry; $timeEntry->disableAuditing(); $timeEntry->user_id = $userId; $timeEntry->member_id = $memberId; $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->client_id = $clientId; $timeEntry->organization_id = $this->organization->id; $timeEntry->description = $record['description']; if (! in_array($record['billable'], ['true', 'false'], true)) { throw new ImportException('Invalid billable value'); } $timeEntry->billable = $record['billable'] === 'true'; $timeEntry->tags = $this->getTags($record['tags']); $timeEntry->is_imported = true; try { $start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['start'], 'UTC'); } catch (InvalidFormatException) { throw new ImportException('Value of start ("'.$record['start'].'") is invalid'); } if ($start === null) { throw new ImportException('Value of start ("'.$record['start'].'") is invalid'); } $timeEntry->start = $start->utc(); try { $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['end'], 'UTC'); } catch (InvalidFormatException) { throw new ImportException('Value of end ("'.$record['end'].'") is invalid'); } if ($end === null) { throw new ImportException('Value of end ("'.$record['end'].'") is invalid'); } $timeEntry->end = $end->utc(); $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry, $projectMember, $project, $member, $this->organization ); $timeEntry->save(); $this->timeEntriesCreated++; } foreach ($this->projectImportHelper->getCachedModels() as $usedProject) { RecalculateSpentTimeForProject::dispatch($usedProject); } foreach ($this->taskImportHelper->getCachedModels() as $usedTask) { RecalculateSpentTimeForTask::dispatch($usedTask); } } catch (ImportException $exception) { throw $exception; } catch (CsvException $exception) { throw new ImportException('Invalid CSV data'); } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); } } /** * @param array $header * * @throws ImportException */ private function validateHeader(array $header): void { foreach (self::REQUIRED_FIELDS as $requiredField) { if (! in_array($requiredField, $header, true)) { throw new ImportException('Invalid CSV header, missing field: '.$requiredField); } } } #[\Override] public function getName(): string { return __('importer.generic_time_entries.name'); } #[\Override] public function getDescription(): string { return __('importer.generic_time_entries.description'); } } ================================================ FILE: app/Service/Import/Importers/HarvestClientsImporter.php ================================================ */ private const array REQUIRED_FIELDS = [ 'Client Name', ]; /** * @throws ImportException */ #[\Override] public function importData(string $data, string $timezone): void { try { $reader = Reader::createFromString($data); $reader->setHeaderOffset(0); $reader->setDelimiter(','); $reader->setEnclosure('"'); $reader->setEscape(''); $header = $reader->getHeader(); $this->validateHeader($header); $records = $reader->getRecords(); foreach ($records as $record) { $this->clientImportHelper->getKey([ 'name' => $record['Client Name'], 'organization_id' => $this->organization->id, ]); } } catch (ImportException $exception) { throw $exception; } catch (CsvException $exception) { throw new ImportException('Invalid CSV data'); } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); } } /** * @param array $header * * @throws ImportException */ private function validateHeader(array $header): void { foreach (self::REQUIRED_FIELDS as $requiredField) { if (! in_array($requiredField, $header, true)) { throw new ImportException('Invalid CSV header, missing field: '.$requiredField); } } } #[\Override] public function getName(): string { return __('importer.harvest_clients.name'); } #[\Override] public function getDescription(): string { return __('importer.harvest_clients.description'); } } ================================================ FILE: app/Service/Import/Importers/HarvestProjectsImporter.php ================================================ */ private const array REQUIRED_FIELDS = [ 'Client', 'Project', 'Budget', 'Billable Hours', ]; /** * @throws ImportException */ #[\Override] public function importData(string $data, string $timezone): void { try { $reader = Reader::createFromString($data); $reader->setHeaderOffset(0); $reader->setDelimiter(','); $reader->setEnclosure('"'); $reader->setEscape(''); $header = $reader->getHeader(); $this->validateHeader($header); $records = $reader->getRecords(); foreach ($records as $record) { $clientId = null; if ($record['Client'] !== '') { $clientId = $this->clientImportHelper->getKey([ 'name' => $record['Client'], 'organization_id' => $this->organization->id, ]); } if ($record['Project'] !== '') { if (! isset($record['Budget']) || ! is_string($record['Budget'])) { throw new ImportException('The value for "Budget" is invalid'); } $estimatedTimeField = Str::replace(',', '.', $record['Budget']); $estimatedTime = $estimatedTimeField !== '' && is_numeric($estimatedTimeField) ? (int) (((float) $estimatedTimeField) * 60 * 60) : null; if ($estimatedTime === 0) { $estimatedTime = null; } if (! isset($record['Billable Hours']) || ! is_string($record['Billable Hours'])) { throw new ImportException('The value for "Billable Hours" is invalid'); } $billableHoursField = Str::replace(',', '.', $record['Billable Hours']); $billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null; $this->projectImportHelper->getKey([ 'name' => $record['Project'], 'client_id' => $clientId, 'organization_id' => $this->organization->id, ], [ 'color' => $this->colorService->getRandomColor(), 'estimated_time' => $estimatedTime, 'is_billable' => $billableHours > 0, ]); } } } catch (ImportException $exception) { throw $exception; } catch (CsvException $exception) { throw new ImportException('Invalid CSV data'); } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); } } /** * @param array $header * * @throws ImportException */ private function validateHeader(array $header): void { foreach (self::REQUIRED_FIELDS as $requiredField) { if (! in_array($requiredField, $header, true)) { throw new ImportException('Invalid CSV header, missing field: '.$requiredField); } } } #[\Override] public function getName(): string { return __('importer.harvest_projects.name'); } #[\Override] public function getDescription(): string { return __('importer.harvest_projects.description'); } } ================================================ FILE: app/Service/Import/Importers/HarvestTimeEntriesImporter.php ================================================ */ private const array REQUIRED_FIELDS = [ 'Date', 'Hours', 'Client', 'Project', 'Task', 'Billable?', 'First Name', 'Last Name', 'Notes', ]; /** * @throws ImportException */ #[Override] public function importData(string $data, string $timezone): void { try { $reader = Reader::createFromString($data); $reader->setHeaderOffset(0); $reader->setDelimiter(','); $reader->setEnclosure('"'); $reader->setEscape(''); $header = $reader->getHeader(); $this->validateHeader($header); $records = $reader->getRecords(); foreach ($records as $record) { $firstname = $record['First Name']; $lastname = $record['Last Name']; $userId = $this->userImportHelper->getKey([ 'email' => Str::slug($firstname).'.'.Str::slug($lastname).'@solidtime-import.test', ], [ 'name' => $firstname.' '.$lastname, 'timezone' => 'UTC', 'is_placeholder' => true, ]); $memberId = $this->memberImportHelper->getKey([ 'user_id' => $userId, 'organization_id' => $this->organization->getKey(), ], [ 'role' => Role::Placeholder->value, ]); $member = $this->memberImportHelper->getModelById($memberId); $clientId = null; if ($record['Client'] !== '') { $clientId = $this->clientImportHelper->getKey([ 'name' => $record['Client'], 'organization_id' => $this->organization->id, ]); } $projectId = null; $project = null; $projectMember = null; if ($record['Project'] !== '') { $projectId = $this->projectImportHelper->getKey([ 'name' => $record['Project'], 'client_id' => $clientId, 'organization_id' => $this->organization->id, ], [ 'color' => $this->colorService->getRandomColor(), 'is_billable' => true, ]); $project = $this->projectImportHelper->getModelById($projectId); $projectMember = $this->projectMemberImportHelper->getModel([ 'project_id' => $projectId, 'member_id' => $memberId, ]); } $taskId = null; if ($record['Task'] !== '') { $taskId = $this->taskImportHelper->getKey([ 'name' => $record['Task'], 'project_id' => $projectId, 'organization_id' => $this->organization->id, ]); $this->taskImportHelper->getModelById($taskId); } $timeEntry = new TimeEntry; $timeEntry->disableAuditing(); $timeEntry->user_id = $userId; $timeEntry->member_id = $memberId; $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->client_id = $clientId; $timeEntry->organization_id = $this->organization->id; if (strlen($record['Notes']) > 5000) { throw new ImportException('Time entry note is too long'); } $timeEntry->description = $record['Notes']; if (! in_array($record['Billable?'], ['Yes', 'No'], true)) { throw new ImportException('Invalid billable value'); } $timeEntry->billable = $record['Billable?'] === 'Yes'; $timeEntry->tags = []; $timeEntry->is_imported = true; // Start & End try { $date = Carbon::createFromFormat('Y-m-d', $record['Date'], $timezone); } catch (InvalidFormatException) { throw new ImportException('Date ("'.$record['Date'].'") is invalid'); } if ($date === null) { throw new ImportException('Date ("'.$record['Date'].'") is invalid'); } if (! isset($record['Hours']) || ! is_string($record['Hours'])) { throw new ImportException('Hours ("'.($record['Hours'] ?? '').'") is invalid'); } $hoursField = Str::replace(',', '.', $record['Hours']); if (! is_numeric($hoursField)) { throw new ImportException('Hours ("'.$record['Hours'].'") is invalid'); } $hours = (float) $hoursField; $timeEntry->start = $date->copy()->startOfDay()->utc(); $timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc(); $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry, $projectMember, $project, $member, $this->organization ); $timeEntry->save(); $this->timeEntriesCreated++; } foreach ($this->projectImportHelper->getCachedModels() as $usedProject) { RecalculateSpentTimeForProject::dispatch($usedProject); } foreach ($this->taskImportHelper->getCachedModels() as $usedTask) { RecalculateSpentTimeForTask::dispatch($usedTask); } } catch (ImportException $exception) { throw $exception; } catch (CsvException $exception) { throw new ImportException('Invalid CSV data'); } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); } } /** * @param array $header * * @throws ImportException */ private function validateHeader(array $header): void { foreach (self::REQUIRED_FIELDS as $requiredField) { if (! in_array($requiredField, $header, true)) { throw new ImportException('Invalid CSV header, missing field: '.$requiredField); } } } #[Override] public function getName(): string { return __('importer.harvest_time_entries.name'); } #[Override] public function getDescription(): string { return __('importer.harvest_time_entries.description'); } } ================================================ FILE: app/Service/Import/Importers/ImportException.php ================================================ > */ private array $importers = [ 'toggl_time_entries' => TogglTimeEntriesImporter::class, 'toggl_data_importer' => TogglDataImporter::class, 'clockify_time_entries' => ClockifyTimeEntriesImporter::class, 'clockify_projects' => ClockifyProjectsImporter::class, 'solidtime' => SolidtimeImporter::class, 'harvest_projects' => HarvestProjectsImporter::class, 'harvest_time_entries' => HarvestTimeEntriesImporter::class, 'harvest_clients' => HarvestClientsImporter::class, 'generic_projects' => GenericProjectsImporter::class, 'generic_time_entries' => GenericTimeEntriesImporter::class, ]; /** * @param class-string $importer */ public function registerImporter(string $type, string $importer): void { $this->importers[$type] = $importer; } /** * @return array */ public function getImporterKeys(): array { return array_keys($this->importers); } /** * @return array> */ public function getImporters(): array { return $this->importers; } public function getImporter(string $type): ImporterContract { if (! array_key_exists($type, $this->importers)) { throw new \InvalidArgumentException('Invalid importer type'); } return new $this->importers[$type]; } } ================================================ FILE: app/Service/Import/Importers/ReportDto.php ================================================ clientsCreated = $clientsCreated; $this->projectsCreated = $projectsCreated; $this->tasksCreated = $tasksCreated; $this->timeEntriesCreated = $timeEntriesCreated; $this->tagsCreated = $tagsCreated; $this->usersCreated = $usersCreated; } /** * @return array{ * clients: array{ * created: int, * }, * projects: array{ * created: int, * }, * tasks: array{ * created: int, * }, * time_entries: array{ * created: int, * }, * tags: array{ * created: int, * }, * users: array{ * created: int, * } * } */ public function toArray(): array { return [ 'clients' => [ 'created' => $this->clientsCreated, ], 'projects' => [ 'created' => $this->projectsCreated, ], 'tasks' => [ 'created' => $this->tasksCreated, ], 'time_entries' => [ 'created' => $this->timeEntriesCreated, ], 'tags' => [ 'created' => $this->tagsCreated, ], 'users' => [ 'created' => $this->usersCreated, ], ]; } } ================================================ FILE: app/Service/Import/Importers/SolidtimeImporter.php ================================================ */ public const array SUPPORTED_VERSIONS = ['1.0']; /** * @throws ImportException */ #[Override] public function importData(string $data, string $timezone): void { $temporaryDirectoryZip = null; $temporaryDirectory = null; try { $zip = new ZipArchive; $temporaryDirectoryZip = TemporaryDirectory::make(); file_put_contents($temporaryDirectoryZip->path('import.zip'), $data); $res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY); if ($res !== true) { throw new ImportException('Invalid ZIP, error code: '.$res); } $temporaryDirectory = TemporaryDirectory::make(); $zip->extractTo($temporaryDirectory->path()); $zip->close(); if (! file_exists($temporaryDirectory->path('meta.json'))) { throw new ImportException('File "meta.json" missing in ZIP'); } $metaFileContentRaw = file_get_contents($temporaryDirectory->path('meta.json')); if ($metaFileContentRaw === false) { throw new ImportException('File "meta.json" can not read'); } $metaFileContent = json_decode($metaFileContentRaw); if ($metaFileContent === false || ! isset($metaFileContent->version) || ! in_array($metaFileContent->version, self::SUPPORTED_VERSIONS, true)) { throw new ImportException('Invalid version'); } if (! file_exists($temporaryDirectory->path('clients.csv'))) { throw new ImportException('File "clients.csv" missing in ZIP'); } $clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv')); $clientsReader->setHeaderOffset(0); $clientsReader->setDelimiter(','); $clientsReader->setEnclosure('"'); $clientsReader->setEscape(''); if (! file_exists($temporaryDirectory->path('members.csv'))) { throw new ImportException('File "members.csv" missing in ZIP'); } $membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv')); $membersReader->setHeaderOffset(0); $membersReader->setDelimiter(','); $membersReader->setEnclosure('"'); $membersReader->setEscape(''); if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) { throw new ImportException('File "organization_invitations.csv" missing in ZIP'); } $organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv')); $organizationInvitationsReader->setHeaderOffset(0); $organizationInvitationsReader->setDelimiter(','); $organizationInvitationsReader->setEnclosure('"'); $organizationInvitationsReader->setEscape(''); if (! file_exists($temporaryDirectory->path('project_members.csv'))) { throw new ImportException('File "project_members.csv" missing in ZIP'); } $projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv')); $projectMembersReader->setHeaderOffset(0); $projectMembersReader->setDelimiter(','); $projectMembersReader->setEnclosure('"'); $projectMembersReader->setEscape(''); if (! file_exists($temporaryDirectory->path('projects.csv'))) { throw new ImportException('File "projects.csv" missing in ZIP'); } $projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv')); $projectsReader->setHeaderOffset(0); $projectsReader->setDelimiter(','); $projectsReader->setEnclosure('"'); $projectsReader->setEscape(''); if (! file_exists($temporaryDirectory->path('tags.csv'))) { throw new ImportException('File "tags.csv" missing in ZIP'); } $tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv')); $tagsReader->setHeaderOffset(0); $tagsReader->setDelimiter(','); $tagsReader->setEnclosure('"'); $tagsReader->setEscape(''); if (! file_exists($temporaryDirectory->path('tasks.csv'))) { throw new ImportException('File "tasks.csv" missing in ZIP'); } $tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv')); $tasksReader->setHeaderOffset(0); $tasksReader->setDelimiter(','); $tasksReader->setEnclosure('"'); $tasksReader->setEscape(''); if (! file_exists($temporaryDirectory->path('time_entries.csv'))) { throw new ImportException('File "time_entries.csv" missing in ZIP'); } $timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv')); $timeEntriesReader->setHeaderOffset(0); $timeEntriesReader->setDelimiter(','); $timeEntriesReader->setEnclosure('"'); $timeEntriesReader->setEscape(''); foreach ($clientsReader as $client) { $this->clientImportHelper->getKey([ 'name' => $client['name'], 'organization_id' => $this->organization->id, ], [ 'archived_at' => $client['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $client['archived_at'], 'UTC') : null, ], $client['id']); } foreach ($tagsReader as $tag) { $this->tagImportHelper->getKey([ 'name' => $tag['name'], 'organization_id' => $this->organization->id, ], [], $tag['id']); } foreach ($membersReader as $member) { $userId = $this->userImportHelper->getKey([ 'email' => $member['email'], ], [ 'name' => $member['name'], 'timezone' => 'UTC', 'is_placeholder' => true, ], $member['user_id']); $this->memberImportHelper->getKey([ 'user_id' => $userId, 'organization_id' => $this->organization->getKey(), ], [ 'role' => Role::Placeholder->value, 'billable_rate' => $member['billable_rate'] === '' ? null : (int) $member['billable_rate'], ], $member['id']); } foreach ($projectsReader as $project) { $clientId = null; if ($project['client_id'] !== '') { $clientId = $this->clientImportHelper->getKeyByExternalIdentifier($project['client_id']); if ($clientId === null) { throw new Exception('Client does not exist'); } } if (! $this->colorService->isValid($project['color'])) { throw new ImportException('Invalid color'); } $this->projectImportHelper->getKey([ 'name' => $project['name'], 'client_id' => $clientId, 'organization_id' => $this->organization->getKey(), ], [ 'color' => $project['color'], 'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'], 'is_public' => $project['is_public'] === 'true', 'is_billable' => $project['is_billable'] === 'true', 'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null, ], $project['id']); } foreach ($projectMembersReader as $projectMember) { $userId = $this->userImportHelper->getKeyByExternalIdentifier($projectMember['user_id']); $memberId = $this->memberImportHelper->getKeyByExternalIdentifier($projectMember['member_id']); $projectId = $this->projectImportHelper->getKeyByExternalIdentifier($projectMember['project_id']); $this->projectMemberImportHelper->getKey([ 'project_id' => $projectId, 'member_id' => $memberId, ], [ 'user_id' => $userId, 'billable_rate' => $projectMember['billable_rate'] === '' ? null : (int) $projectMember['billable_rate'], ], $projectMember['id']); } foreach ($tasksReader as $task) { $projectId = $this->projectImportHelper->getKeyByExternalIdentifier($task['project_id']); if ($projectId === null) { throw new Exception('Project does not exist'); } $this->taskImportHelper->getKey([ 'name' => $task['name'], 'project_id' => $projectId, 'organization_id' => $this->organization->getKey(), ], [ 'done_at' => $task['done_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $task['done_at'], 'UTC') : null, ], (string) $task['id']); } // Time entries foreach ($timeEntriesReader as $timeEntryRow) { $userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']); $memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']); $member = $this->memberImportHelper->getModelById($memberId); $clientId = null; if ($timeEntryRow['client_id'] !== '') { $clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']); } $project = null; $projectId = null; $projectMember = null; if ($timeEntryRow['project_id'] !== '') { $projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']); $project = $this->projectImportHelper->getModelById($projectId); $projectMember = $this->projectMemberImportHelper->getModel([ 'project_id' => $projectId, 'member_id' => $memberId, ]); } $taskId = null; if ($timeEntryRow['task_id'] !== '') { $taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']); $this->taskImportHelper->getModelById($taskId); } $timeEntry = new TimeEntry; $timeEntry->disableAuditing(); $timeEntry->user_id = $userId; $timeEntry->member_id = $memberId; $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->client_id = $clientId; $timeEntry->organization_id = $this->organization->id; if (strlen($timeEntryRow['description']) > 5000) { throw new ImportException('Time entry description is too long'); } $timeEntry->description = $timeEntryRow['description']; if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) { throw new ImportException('Invalid billable value'); } $timeEntry->billable = $timeEntryRow['billable'] === 'true'; $timeEntry->tags = $this->getTags($timeEntryRow['tags']); $timeEntry->is_imported = true; try { $start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['start'], 'UTC'); } catch (InvalidFormatException) { throw new ImportException('Start date ("'.$timeEntryRow['start'].'") is invalid'); } if ($start === null) { throw new ImportException('Start date ("'.$timeEntryRow['start'].'") is invalid'); } $timeEntry->start = $start->utc(); if ($timeEntryRow['end'] !== '') { try { $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC'); } catch (InvalidFormatException) { throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid'); } if ($end === null) { throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid'); } $timeEntry->end = $end->utc(); } else { $timeEntry->end = null; } if ($timeEntryRow['still_active_email_sent_at'] !== '') { try { $stillActiveEmailSentAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['still_active_email_sent_at'], 'UTC'); } catch (InvalidFormatException) { throw new ImportException('Still active email timestamp ("'.$timeEntryRow['still_active_email_sent_at'].'") is invalid'); } if ($stillActiveEmailSentAt === null) { throw new ImportException('Still active email timestamp ("'.$timeEntryRow['still_active_email_sent_at'].'") is invalid'); } $timeEntry->still_active_email_sent_at = $stillActiveEmailSentAt->utc(); } else { $timeEntry->still_active_email_sent_at = null; } $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry, $projectMember, $project, $member, $this->organization ); $timeEntry->save(); $this->timeEntriesCreated++; } foreach ($this->projectImportHelper->getCachedModels() as $usedProject) { RecalculateSpentTimeForProject::dispatch($usedProject); } foreach ($this->taskImportHelper->getCachedModels() as $usedTask) { RecalculateSpentTimeForTask::dispatch($usedTask); } } catch (ImportException $exception) { throw $exception; } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); } finally { $temporaryDirectory?->delete(); $temporaryDirectoryZip?->delete(); } } /** * @return array */ private function getTags(string $tags): array { if (Str::trim($tags) === '') { return []; } $tagsParsed = json_decode($tags); if ($tagsParsed === false || ! is_array($tagsParsed)) { return []; } $tagIds = []; foreach ($tagsParsed as $tagParsed) { if (! is_string($tagParsed) || ! Str::isUuid($tagParsed)) { continue; } $tagId = $this->tagImportHelper->getKeyByExternalIdentifier($tagParsed); $tagIds[] = $tagId; } return $tagIds; } #[Override] public function getName(): string { return __('importer.solidtime_importer.name'); } #[Override] public function getDescription(): string { return __('importer.solidtime_importer.description'); } } ================================================ FILE: app/Service/Import/Importers/TogglDataImporter.php ================================================ path('import.zip'), $data); $res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY); if ($res !== true) { throw new ImportException('Invalid ZIP, error code: '.$res); } $temporaryDirectory = TemporaryDirectory::make(); $zip->extractTo($temporaryDirectory->path()); $zip->close(); if (! file_exists($temporaryDirectory->path('clients.json'))) { throw new ImportException('File "clients.json" missing in ZIP'); } $clientsFileContent = file_get_contents($temporaryDirectory->path('clients.json')); if ($clientsFileContent === false) { throw new ImportException('File "clients.json" can not be opened'); } $clients = json_decode($clientsFileContent); if ($clients === null) { throw new ImportException('File "clients.json" is empty'); } if (! file_exists($temporaryDirectory->path('projects.json'))) { throw new ImportException('File "projects.json" missing in ZIP'); } $projectsFileContent = file_get_contents($temporaryDirectory->path('projects.json')); if ($projectsFileContent === false) { throw new ImportException('File "projects.json" can not be opened'); } $projects = json_decode($projectsFileContent); if ($projects === null) { throw new ImportException('File "projects.json" is empty'); } if (! file_exists($temporaryDirectory->path('tags.json'))) { throw new ImportException('File "tags.json" missing in ZIP'); } $tagsFileContent = file_get_contents($temporaryDirectory->path('tags.json')); if ($tagsFileContent === false) { throw new ImportException('File "tags.json" can not be opened'); } $tags = json_decode($tagsFileContent); if ($tags === null) { throw new ImportException('File "tags.json" is empty'); } if (! file_exists($temporaryDirectory->path('workspace_users.json'))) { throw new ImportException('File "workspace_users.json" missing in ZIP'); } $workspaceUsersFileContent = file_get_contents($temporaryDirectory->path('workspace_users.json')); if ($workspaceUsersFileContent === false) { throw new ImportException('File "workspace_users.json" can not be opened'); } $workspaceUsers = json_decode($workspaceUsersFileContent); if ($workspaceUsers === null) { throw new ImportException('File "workspace_users.json" is empty'); } foreach ($clients as $client) { $this->clientImportHelper->getKey([ 'name' => $client->name, 'organization_id' => $this->organization->id, ], [ 'archived_at' => $client->archived === true ? Carbon::now() : null, ], (string) $client->id); } foreach ($tags as $tag) { $this->tagImportHelper->getKey([ 'name' => $tag->name, 'organization_id' => $this->organization->id, ], [], (string) $tag->id); } foreach ($workspaceUsers as $workspaceUser) { $timezone = Str::trim($workspaceUser->timezone); if ($timezone === '') { $timezone = 'UTC'; } if (! app(TimezoneService::class)->isValid($timezone)) { Log::warning('TogglDateImporter: Invalid timezone', [ 'timezone' => $timezone, ]); $timezone = 'UTC'; } $userId = $this->userImportHelper->getKey([ 'email' => $workspaceUser->email, ], [ 'name' => $workspaceUser->name, 'timezone' => $timezone, 'is_placeholder' => true, ], (string) $workspaceUser->uid); $this->memberImportHelper->getKey([ 'user_id' => $userId, 'organization_id' => $this->organization->getKey(), ], [ 'role' => Role::Placeholder->value, ], $userId); } foreach ($projects as $project) { $clientId = null; if ($project->client_id !== null) { $clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id); if ($clientId === null) { throw new Exception('Client does not exist'); } } if (! $this->colorService->isValid($project->color)) { throw new ImportException('Invalid color'); } $projectId = $this->projectImportHelper->getKey([ 'name' => $project->name, 'client_id' => $clientId, 'organization_id' => $this->organization->getKey(), ], [ 'color' => $project->color, 'is_billable' => $project->billable, 'is_public' => ! $project->is_private, 'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null, ], (string) $project->id); if (! file_exists($temporaryDirectory->path('projects_users/'.$project->id.'.json'))) { throw new ImportException('File "projects_users/'.$project->id.'.json" missing in ZIP'); } $projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$project->id.'.json')); if ($projectMembersFileContent === false) { throw new ImportException('File "projects_users/'.$project->id.'.json" can not be opened'); } $projectMembers = json_decode($projectMembersFileContent); if ($projectMembers === null) { throw new ImportException('File "projects_users/'.$project->id.'.json" is empty'); } foreach ($projectMembers as $projectMember) { $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id); $this->projectMemberImportHelper->getKey([ 'project_id' => $projectId, 'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId), ], [ 'user_id' => $userId, 'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null, ]); } } $projectIds = $this->projectImportHelper->getExternalIds(); foreach ($projectIds as $projectIdExternal) { if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) { continue; } $tasksFileContent = file_get_contents($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json')); if ($tasksFileContent === false) { throw new ImportException('File "tasks/'.$projectIdExternal.'.json" can not be opened'); } $tasks = json_decode($tasksFileContent); if ($tasks === null) { throw new ImportException('File "tasks/'.$projectIdExternal.'.json" is empty'); } foreach ($tasks as $task) { $projectId = $this->projectImportHelper->getKeyByExternalIdentifier((string) $projectIdExternal); if ($projectId === null) { throw new Exception('Project does not exist'); } $this->taskImportHelper->getKey([ 'name' => $task->name, 'project_id' => $projectId, 'organization_id' => $this->organization->getKey(), ], [ 'done_at' => $task->active === false ? Carbon::now() : null, ], (string) $task->id); } } } catch (ValueError $exception) { } catch (ImportException $exception) { throw $exception; } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); } finally { $temporaryDirectory?->delete(); $temporaryDirectoryZip?->delete(); } } #[Override] public function getName(): string { return __('importer.toggl_data_importer.name'); } #[Override] public function getDescription(): string { return __('importer.toggl_data_importer.description'); } } ================================================ FILE: app/Service/Import/Importers/TogglTimeEntriesImporter.php ================================================ * * @throws ImportException */ private function getTags(string $tags): array { if (Str::trim($tags) === '') { return []; } $tagsParsed = explode(', ', $tags); $tagIds = []; foreach ($tagsParsed as $tagParsed) { $tagId = $this->tagImportHelper->getKey([ 'name' => $tagParsed, 'organization_id' => $this->organization->id, ]); $tagIds[] = $tagId; } return $tagIds; } /** * @throws ImportException */ #[\Override] public function importData(string $data, string $timezone): void { try { $reader = Reader::createFromString($data); $reader->setHeaderOffset(0); $reader->setDelimiter(','); $reader->setEnclosure('"'); $reader->setEscape(''); $header = $reader->getHeader(); $this->validateHeader($header); $records = $reader->getRecords(); foreach ($records as $record) { $userId = $this->userImportHelper->getKey([ 'email' => $record['Email'], ], [ 'name' => $record['User'], 'timezone' => 'UTC', 'is_placeholder' => true, ]); $memberId = $this->memberImportHelper->getKey([ 'user_id' => $userId, 'organization_id' => $this->organization->getKey(), ], [ 'role' => Role::Placeholder->value, ]); $member = $this->memberImportHelper->getModelById($memberId); $clientId = null; if ($record['Client'] !== '') { $clientId = $this->clientImportHelper->getKey([ 'name' => $record['Client'], 'organization_id' => $this->organization->id, ]); } $projectId = null; $project = null; $projectMember = null; if ($record['Project'] !== '') { $projectId = $this->projectImportHelper->getKey([ 'name' => $record['Project'], 'client_id' => $clientId, 'organization_id' => $this->organization->id, ], [ 'is_billable' => false, 'color' => $this->colorService->getRandomColor(), ]); $project = $this->projectImportHelper->getModelById($projectId); $projectMember = $this->projectMemberImportHelper->getModel([ 'project_id' => $projectId, 'member_id' => $memberId, ]); } $taskId = null; if ($record['Task'] !== '') { $taskId = $this->taskImportHelper->getKey([ 'name' => $record['Task'], 'project_id' => $projectId, 'organization_id' => $this->organization->id, ]); $this->taskImportHelper->getModelById($taskId); } $timeEntry = new TimeEntry; $timeEntry->disableAuditing(); $timeEntry->user_id = $userId; $timeEntry->member_id = $memberId; $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->client_id = $clientId; $timeEntry->organization_id = $this->organization->id; $timeEntry->description = $record['Description']; if (! in_array($record['Billable'], ['Yes', 'No'], true)) { throw new ImportException('Invalid billable value'); } $timeEntry->billable = $record['Billable'] === 'Yes'; $timeEntry->tags = $this->getTags($record['Tags']); $timeEntry->is_imported = true; try { $start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], $timezone); } catch (InvalidFormatException) { throw new ImportException('Start date ("'.$record['Start date'].'") or time ("'.$record['Start time'].'") are invalid'); } if ($start === null) { throw new ImportException('Start date ("'.$record['Start date'].'") or time ("'.$record['Start time'].'") are invalid'); } $timeEntry->start = $start->utc(); try { $end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], $timezone); } catch (InvalidFormatException) { throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid'); } if ($end === null) { throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid'); } $timeEntry->end = $end->utc(); $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry, $projectMember, $project, $member, $this->organization ); $timeEntry->save(); $this->timeEntriesCreated++; } foreach ($this->projectImportHelper->getCachedModels() as $usedProject) { RecalculateSpentTimeForProject::dispatch($usedProject); } foreach ($this->taskImportHelper->getCachedModels() as $usedTask) { RecalculateSpentTimeForTask::dispatch($usedTask); } } catch (ImportException $exception) { throw $exception; } catch (CsvException $exception) { throw new ImportException('Invalid CSV data'); } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); } } /** * @param array $header * * @throws ImportException */ private function validateHeader(array $header): void { $requiredFields = [ 'User', 'Email', 'Client', 'Project', 'Task', 'Description', 'Billable', 'Start date', 'Start time', 'End date', 'End time', 'Tags', ]; foreach ($requiredFields as $requiredField) { if (! in_array($requiredField, $header, true)) { throw new ImportException('Invalid CSV header, missing field: '.$requiredField); } } } #[\Override] public function getName(): string { return __('importer.toggl_time_entries.name'); } #[\Override] public function getDescription(): string { return __('importer.toggl_time_entries.description'); } } ================================================ FILE: app/Service/IntervalService.php ================================================ cascade(); return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S'); } } ================================================ FILE: app/Service/InvitationService.php ================================================ whereBelongsTo($organization, 'organization') ->whereRelation('user', 'email', '=', $email) ->where('role', '!=', Role::Placeholder->value) ->exists()) { throw new UserIsAlreadyMemberOfOrganizationApiException; } if (OrganizationInvitation::query() ->where('email', $email) ->whereBelongsTo($organization, 'organization') ->exists()) { throw new InvitationForTheEmailAlreadyExistsApiException; } InvitingTeamMember::dispatch($organization, $email, $role->value); $invitation = new OrganizationInvitation; $invitation->email = $email; $invitation->role = $role->value; $invitation->organization()->associate($organization); $invitation->save(); Mail::to($email)->queue(new OrganizationInvitationMail($invitation)); return $invitation; } } ================================================ FILE: app/Service/IpLookup/IpLookupResponseDto.php ================================================ timezone = $timezone; $this->startOfWeek = $startOfWeek; $this->currency = $currency; } } ================================================ FILE: app/Service/IpLookup/IpLookupServiceContract.php ================================================ currencyFormat = $currencyFormat; $this->dateFormat = $dateFormat; $this->timeFormat = $timeFormat; $this->numberFormat = $numberFormat; $this->intervalFormat = $intervalFormat; } public static function forOrganization(Organization $organization): self { return new LocalizationService( $organization->currency_format, $organization->date_format, $organization->time_format, $organization->number_format, $organization->interval_format ); } public function formatNumber(BigDecimal|float $number): string { $numberFloat = $number instanceof BigDecimal ? $number->toFloat() : $number; if ($this->numberFormat === NumberFormat::ThousandsPointDecimalComma) { return number_format($numberFloat, 2, ',', '.'); } elseif ($this->numberFormat === NumberFormat::ThousandsSpaceDecimalPoint) { return number_format($numberFloat, 2, '.', ' '); } elseif ($this->numberFormat === NumberFormat::ThousandsCommaDecimalPoint) { return number_format($numberFloat, 2, '.', ','); } elseif ($this->numberFormat === NumberFormat::ThousandsSpaceDecimalComma) { return number_format($numberFloat, 2, ',', ' '); } elseif ($this->numberFormat === NumberFormat::ThousandsApostropheDecimalPoint) { return number_format($numberFloat, 2, '.', '\''); } } public function formatNumberWithoutTrailingZeros(BigDecimal|float $number): string { $number = $this->formatNumber($number); $number = rtrim($number, '0'); $number = rtrim($number, '.'); $number = rtrim($number, ','); return $number; } public function formatInterval(CarbonInterval $interval): string { if ($this->intervalFormat === IntervalFormat::Decimal) { $interval->cascade(); return $this->formatNumber($interval->totalHours).' h'; } elseif ($this->intervalFormat === IntervalFormat::HoursMinutes) { $interval->cascade(); return ((int) floor($interval->totalHours)).'h '.$interval->format('%I').'m'; } elseif ($this->intervalFormat === IntervalFormat::HoursMinutesColonSeparated) { $interval->cascade(); return ((int) floor($interval->totalHours)).':'.$interval->format('%I'); } elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeparated) { $interval->cascade(); return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S'); } } public function formatCurrency(Money $money): string { $currencyService = app(CurrencyService::class); if ($this->currencyFormat === CurrencyFormat::ISOCodeAfterWithSpace) { return $this->formatNumber($money->getAmount()).' '.$money->getCurrency()->getCurrencyCode(); } elseif ($this->currencyFormat === CurrencyFormat::ISOCodeBeforeWithSpace) { return $money->getCurrency()->getCurrencyCode().' '.$this->formatNumber($money->getAmount()); } elseif ($this->currencyFormat === CurrencyFormat::SymbolAfter) { return $this->formatNumber($money->getAmount()).$currencyService->getCurrencySymbolForMoney($money); } elseif ($this->currencyFormat === CurrencyFormat::SymbolBefore) { return $currencyService->getCurrencySymbolForMoney($money).$this->formatNumber($money->getAmount()); } elseif ($this->currencyFormat === CurrencyFormat::SymbolBeforeWithSpace) { return $currencyService->getCurrencySymbolForMoney($money).' '.$this->formatNumber($money->getAmount()); } elseif ($this->currencyFormat === CurrencyFormat::SymbolAfterWithSpace) { return $this->formatNumber($money->getAmount()).' '.$currencyService->getCurrencySymbolForMoney($money); } } public function formatTime(CarbonInterface $time): string { if ($this->timeFormat === TimeFormat::TwelveHours) { return $time->format('h:i a'); // Examples: "11:01 am", "1:02 am" } elseif ($this->timeFormat === TimeFormat::TwentyFourHours) { return $time->format('H:i'); // Examples: "23:01", "01:02" } } public function formatDate(CarbonInterface $date): string { return $date->format($this->dateFormat->toCarbonFormat()); } public function setDateFormat(DateFormat $dateFormat): void { $this->dateFormat = $dateFormat; } public function setCurrencyFormat(CurrencyFormat $currencyFormat): void { $this->currencyFormat = $currencyFormat; } public function setIntervalFormat(IntervalFormat $intervalFormat): void { $this->intervalFormat = $intervalFormat; } public function setTimeFormat(TimeFormat $timeFormat): void { $this->timeFormat = $timeFormat; } public function setNumberFormat(NumberFormat $numberFormat): void { $this->numberFormat = $numberFormat; } } ================================================ FILE: app/Service/MemberService.php ================================================ userService = $userService; } public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member { if (! $asSuperAdmin) { AddingTeamMember::dispatch($organization, $user); } $member = new Member; DB::transaction(function () use ($organization, $user, $role, &$member): void { $member->user()->associate($user); $member->organization()->associate($organization); $member->role = $role->value; $member->save(); $user->currentOrganization()->associate($organization); $user->save(); }); if (! $asSuperAdmin) { TeamMemberAdded::dispatch($organization, $user); } return $member; } /** * @throws CanNotRemoveOwnerFromOrganization * @throws EntityStillInUseApiException */ public function removeMember(Member $member, Organization $organization, bool $withRelations = false): void { if ($member->role === Role::Owner->value) { 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(); if ($isPlaceholder) { $user->delete(); } else { $this->userService->makeSureUserHasAtLeastOneOrganization($user); $this->userService->makeSureUserHasCurrentOrganization($user); } MemberRemoved::dispatch($member, $organization); } /** * @throws ChangingRoleToPlaceholderIsNotAllowed * @throws OnlyOwnerCanChangeOwnership * @throws OrganizationNeedsAtLeastOneOwner * @throws ChangingRoleOfPlaceholderIsNotAllowed */ public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void { $oldRole = Role::from($member->role); if ($oldRole === Role::Owner) { throw new OrganizationNeedsAtLeastOneOwner; } if ($oldRole === Role::Placeholder) { throw new ChangingRoleOfPlaceholderIsNotAllowed; } if ($newRole === Role::Placeholder) { throw new ChangingRoleToPlaceholderIsNotAllowed; } if ($newRole === Role::Owner) { if ($allowOwnerChange) { $this->changeOwnership($organization, $member); } else { throw new OnlyOwnerCanChangeOwnership; } } else { $member->role = $newRole->value; } } public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void { // Time entries TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->whereBelongsTo($fromMember, 'member') ->update([ 'user_id' => $toMember->user_id, 'member_id' => $toMember->getKey(), ]); // Project members ProjectMember::query() ->whereBelongsToOrganization($organization) ->whereBelongsTo($fromMember, 'member') ->whereDoesntHave('project', function (Builder $builder) use ($toMember): void { /** @var Builder $builder */ $builder->whereHas('members', function (Builder $builder) use ($toMember): void { /** @var Builder $builder */ $builder->where('member_id', $toMember->getKey()); }); }) ->update([ 'user_id' => $toMember->user_id, 'member_id' => $toMember->getKey(), ]); ProjectMember::query() ->whereBelongsToOrganization($organization) ->whereBelongsTo($fromMember, 'member') ->delete(); } /** * Change the ownership of an organization to a new user. * The previous owner will be demoted to an admin. */ public function changeOwnership(Organization $organization, Member $newOwner): void { $organization->update([ 'user_id' => $newOwner->user_id, ]); if ($newOwner->organization_id !== $organization->getKey()) { throw new InvalidArgumentException('Member is not part of the organization'); } $newOwner->role = Role::Owner->value; $newOwner->save(); $oldOwners = Member::query() ->whereBelongsTo($organization, 'organization') ->where('role', '=', Role::Owner->value) ->where('id', '!=', $newOwner->getKey()) ->get(); foreach ($oldOwners as $oldOwner) { $oldOwner->role = Role::Admin->value; $oldOwner->save(); } } public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void { $user = $member->user; if ($user->current_team_id === $member->organization_id) { $user->currentTeam()->disassociate(); $user->save(); } $placeholderUser = $user->replicate(); $placeholderUser->is_placeholder = true; $placeholderUser->current_team_id = $member->organization_id; $placeholderUser->save(); $member->user()->associate($placeholderUser); $member->role = Role::Placeholder->value; $member->save(); $this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser); if ($makeSureUserHasAtLeastOneOrganization) { $this->userService->makeSureUserHasAtLeastOneOrganization($user); $this->userService->makeSureUserHasCurrentOrganization($user); } } } ================================================ FILE: app/Service/OrganizationInvitationService.php ================================================ email) ->queue(new OrganizationInvitationMail($invitation)); } } ================================================ FILE: app/Service/OrganizationService.php ================================================ name = $name; $organization->personal_team = $personalOrganization; if ($currency === null) { $currency = config('app.localization.default_currency'); } $organization->currency = $currency; if ($numberFormat === null) { $numberFormat = NumberFormat::from(config('app.localization.default_number_format')); } $organization->number_format = $numberFormat; if ($currencyFormat === null) { $currencyFormat = CurrencyFormat::from(config('app.localization.default_currency_format')); } $organization->currency_format = $currencyFormat; if ($dateFormat === null) { $dateFormat = DateFormat::from(config('app.localization.default_date_format')); } $organization->date_format = $dateFormat; if ($intervalFormat === null) { $intervalFormat = IntervalFormat::from(config('app.localization.default_interval_format')); } $organization->interval_format = $intervalFormat; if ($timeFormat === null) { $timeFormat = TimeFormat::from(config('app.localization.default_time_format')); } $organization->time_format = $timeFormat; $organization->owner()->associate($owner); $organization->save(); $organization->users()->attach( $owner, [ 'role' => Role::Owner->value, ] ); return $organization; } } ================================================ FILE: app/Service/PermissionStore.php ================================================ > */ private array $permissionCache = []; public function clear(): void { $this->permissionCache = []; } public function has(Organization $organization, string $permission): bool { /** @var User|null $user */ $user = Auth::user(); if ($user === null) { return false; } return $this->userHas($organization, $user, $permission); } public function userHas(Organization $organization, User $user, string $permission): bool { if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) { if (! $user->belongsToTeam($organization)) { return false; } $permissions = $this->getPermissionsByUser($organization, $user); $this->permissionCache[$user->getKey().'|'.$organization->getKey()] = $permissions; } else { $permissions = $this->permissionCache[$user->getKey().'|'.$organization->getKey()]; } return in_array($permission, $permissions, true); } /** * @return array */ private function getPermissionsByUser(Organization $organization, User $user): array { if (! $user->belongsToTeam($organization)) { return []; } $role = $organization->users ->where('id', $user->getKey()) ->first() ?->membership ?->role; if ($role === null) { return []; } /** @var Role|null $roleObj */ $roleObj = Jetstream::findRole($role); $permissions = $roleObj->permissions ?? []; // If the organization allows employees to manage tasks and the user is an employee, // add the task management permissions for accessible projects if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) { $permissions = array_merge($permissions, [ 'tasks:create', 'tasks:update', 'tasks:delete', ]); } return $permissions; } /** * @return array */ public function getPermissions(Organization $organization): array { /** @var User|null $user */ $user = Auth::user(); if ($user === null) { return []; } return $this->getPermissionsByUser($organization, $user); } } ================================================ FILE: app/Service/ReportExport/CsvExport.php ================================================ */ private Builder $builder; private string $folderPath; protected const string CARBON_FORMAT = 'Y-m-d\TH:i:sP'; /** * @param Builder $builder */ public function __construct(string $disk, string $folderPath, string $filename, Builder $builder, int $chunk) { $this->disk = $disk; $this->filename = $filename; $this->chunk = $chunk; $this->builder = $builder; $this->folderPath = $folderPath; } /** * @param T $model * @return array */ abstract public function mapRow(Model $model): array; /** * @throws \League\Csv\CannotInsertRecord * @throws \League\Csv\Exception * @throws \League\Csv\UnavailableStream */ public function export(): void { $tempDirectory = TemporaryDirectory::make(); $writer = Writer::createFromPath($tempDirectory->path($this->filename), 'w+'); $writer->setDelimiter(','); $writer->setEnclosure('"'); $writer->setEscape(''); $writer->insertOne(static::HEADER); $this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void { foreach ($models as $model) { $data = $this->mapRow($model); $row = $this->convertRow($data); $this->validateRow($row); $writer->insertOne(array_values($row)); } }); Storage::disk($this->disk)->putFileAs($this->folderPath, new File($tempDirectory->path($this->filename)), $this->filename); $tempDirectory->delete(); } /** * @param array $data * @return array */ private function convertRow(array $data): array { $convertedRow = []; foreach ($data as $key => $value) { if ($value instanceof Carbon) { $convertedRow[$key] = $value->format(static::CARBON_FORMAT); } elseif (is_float($value)) { $convertedRow[$key] = (string) $value; } elseif ($value === null) { $convertedRow[$key] = ''; } else { $convertedRow[$key] = $value; } } return $convertedRow; } /** * @param array $row */ private function validateRow(array $row): void { if (array_keys($row) !== static::HEADER) { throw new \LogicException('Invalid row'); } } } ================================================ FILE: app/Service/ReportExport/TimeEntriesDetailedCsvExport.php ================================================ */ class TimeEntriesDetailedCsvExport extends CsvExport { public const array HEADER = [ 'Description', 'Task', 'Project', 'Client', 'User', 'Start', 'End', 'Duration', 'Duration (decimal)', 'Billable', 'Tags', ]; protected const string CARBON_FORMAT = 'Y-m-d H:i:s'; private string $timezone; public function __construct(string $disk, string $folderPath, string $filename, Builder $builder, int $chunk, string $timezone) { parent::__construct($disk, $folderPath, $filename, $builder, $chunk); $this->timezone = $timezone; } /** * @param TimeEntry $model */ public function mapRow(Model $model): array { $interval = app(IntervalService::class); $duration = $model->getDuration(); return [ 'Description' => $model->description, 'Task' => $model->task?->name, 'Project' => $model->project?->name, 'Client' => $model->client?->name, 'User' => $model->user->name, 'Start' => $model->start->timezone($this->timezone), 'End' => $model->end->timezone($this->timezone), 'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null, 'Duration (decimal)' => $duration?->totalHours, 'Billable' => $model->billable ? 'Yes' : 'No', 'Tags' => $model->tagsRelation->pluck('name')->implode(', '), ]; } } ================================================ FILE: app/Service/ReportExport/TimeEntriesDetailedExport.php ================================================ */ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithHeadings, WithMapping, WithStyles { use Exportable; /** * @var Builder */ private Builder $builder; private ExportFormat $exportFormat; private string $timezone; private LocalizationService $localizationService; /** * @param Builder $builder */ public function __construct(Builder $builder, ExportFormat $exportFormat, string $timezone, LocalizationService $localizationService) { $this->builder = $builder; $this->exportFormat = $exportFormat; $this->timezone = $timezone; $this->localizationService = $localizationService; } /** * @return Builder */ public function query(): Builder { return $this->builder; } /** * @return array */ public function columnFormats(): array { if ($this->exportFormat === ExportFormat::XLSX) { return [ 'F' => 'yyyy-mm-dd hh:mm:ss', 'G' => 'yyyy-mm-dd hh:mm:ss', 'I' => NumberFormat::FORMAT_NUMBER_00, ]; } elseif ($this->exportFormat === ExportFormat::ODS) { return [ 'I' => NumberFormat::FORMAT_NUMBER_00, ]; } else { throw new LogicException('Unsupported export format.'); } } /** * @return array>> */ public function styles(Worksheet $sheet): array { return [ // Style the first row as bold text. 1 => ['font' => ['bold' => true]], ]; } /** * @return string[] */ public function headings(): array { return [ 'Description', 'Task', 'Project', 'Client', 'User', 'Start', 'End', 'Duration', 'Duration (decimal)', 'Billable', 'Tags', ]; } /** * @param TimeEntry $model * @return array */ public function map($model): array { $duration = $model->getDuration(); if ($this->exportFormat === ExportFormat::XLSX) { return [ $model->description, $model->task?->name, $model->project?->name, $model->client?->name, $model->user->name, Date::dateTimeToExcel($model->start->timezone($this->timezone)), $model->end !== null ? Date::dateTimeToExcel($model->end->timezone($this->timezone)) : null, $duration !== null ? $this->localizationService->formatInterval($duration) : null, $duration?->totalHours, $model->billable ? 'Yes' : 'No', $model->tagsRelation->pluck('name')->implode(', '), ]; } elseif ($this->exportFormat === ExportFormat::ODS) { return [ $model->description, $model->task?->name, $model->project?->name, $model->client?->name, $model->user->name, $model->start->timezone($this->timezone)->format('Y-m-d H:i:s'), $model->end?->timezone($this->timezone)?->format('Y-m-d H:i:s'), $duration !== null ? $this->localizationService->formatInterval($duration) : null, $duration?->totalHours, $model->billable ? 'Yes' : 'No', $model->tagsRelation->pluck('name')->implode(', '), ]; } else { throw new LogicException('Unsupported export format.'); } } } ================================================ FILE: app/Service/ReportExport/TimeEntriesReportExport.php ================================================ * }>, * seconds: int, * cost: int|null * } */ private array $data; private ExportFormat $exportFormat; private string $currency; private TimeEntryAggregationType $group; private TimeEntryAggregationType $subGroup; private bool $showBillableRate; /** * @param array{ * grouped_type: string|null, * grouped_data: null|array * }>, * seconds: int, * cost: int|null * } $data */ public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate) { $this->data = $data; $this->exportFormat = $exportFormat; $this->currency = $currency; $this->group = $group; $this->subGroup = $subGroup; $this->showBillableRate = $showBillableRate; } public function view(): View { return view('reports.time-entry-aggregate.spreadsheet', [ 'data' => $this->data, 'currency' => $this->currency, 'group' => $this->group, 'subGroup' => $this->subGroup, 'exportFormat' => $this->exportFormat, 'showBillableRate' => $this->showBillableRate, ]); } /** * @return array */ public function getCsvSettings(): array { return [ 'delimiter' => ',', 'enclosure' => '"', 'escape_character' => '', ]; } } ================================================ FILE: app/Service/ReportService.php ================================================ $timeEntriesQuery * @return array{ * grouped_type: string|null, * grouped_data: null|array * }>, * seconds: int, * 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 { $fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null; /** @var Builder $baseTotalsQuery */ $baseTotalsQuery = $timeEntriesQuery->clone(); $group1Select = null; $group2Select = null; $groupBy = null; // If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) { $timeEntriesQuery->crossJoin(DB::raw( "LATERAL (\n". " SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n". " UNION ALL\n". " SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n". ') AS tag(tag)' )); } if ($group1Type !== null) { $group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek); $groupBy = ['group_1']; if ($group2Type !== null) { $group2Select = $this->getGroupByQuery($group2Type, $timezone, $startOfWeek); $groupBy = ['group_1', 'group_2']; } } $startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes); $endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes); $timeEntriesQuery->selectRaw( ($group1Select !== null ? $group1Select.' as group_1,' : ''). ($group2Select !== null ? $group2Select.' as group_2,' : ''). ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost' ); if ($groupBy !== null) { $timeEntriesQuery->groupBy($groupBy); } if ($group1Select !== null) { $timeEntriesQuery->orderBy('group_1'); if ($group2Select !== null) { $timeEntriesQuery->orderBy('group_2'); } } $timeEntriesAggregates = $timeEntriesQuery->get(); if ($group1Select !== null) { $groupedAggregates = $timeEntriesAggregates->groupBy($group2Select !== null ? ['group_1', 'group_2'] : ['group_1']); $group1Response = []; $group1ResponseSum = 0; $group1ResponseCost = 0; // If Tag is subgroup, prepare base totals per primary group without tag expansion $baseTotalsPerGroup1Map = []; if ($group2Type === TimeEntryAggregationType::Tag) { $baseTotalsPerGroup1Query = $baseTotalsQuery->clone(); $baseTotalsPerGroup1 = $baseTotalsPerGroup1Query ->selectRaw( $group1Select.' as group_1,'. ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost' ) ->groupBy('group_1') ->get(); foreach ($baseTotalsPerGroup1 as $row) { /** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */ $baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [ 'aggregate' => (int) ($row->aggregate ?? 0), 'cost' => (int) ($row->cost ?? 0), ]; } } foreach ($groupedAggregates as $group1 => $group1Aggregates) { /** @var string|int $group1 */ $group2Response = []; if ($group2Select !== null) { $group2ResponseSum = 0; $group2ResponseCost = 0; foreach ($group1Aggregates as $group2 => $aggregate) { /** @var string|int $group2 */ /** @var Collection $aggregate */ $group2Response[] = [ 'key' => $group2 === '' ? null : (string) $group2, 'seconds' => (int) $aggregate->get(0)->aggregate, 'cost' => $showBillableRate ? (int) $aggregate->get(0)->cost : null, 'grouped_type' => null, 'grouped_data' => null, ]; $group2ResponseSum += (int) $aggregate->get(0)->aggregate; $group2ResponseCost += (int) $aggregate->get(0)->cost; } // Override primary group totals when Tag is subgroup to avoid double counting if ($group2Type === TimeEntryAggregationType::Tag) { $keyForMap = (string) $group1; if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) { $group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate']; $group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost']; } } } else { /** @var Collection $group1Aggregates */ $group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate; $group2ResponseCost = (int) $group1Aggregates->get(0)->cost; $group2Response = null; } $group1Response[] = [ 'key' => $group1 === '' ? null : (string) $group1, 'seconds' => $group2ResponseSum, 'cost' => $showBillableRate ? $group2ResponseCost : null, 'grouped_type' => $group2Type?->value, 'grouped_data' => $group2Response, ]; $group1ResponseSum += $group2ResponseSum; $group1ResponseCost += $group2ResponseCost; } // If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting $hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag); if ($hasTagGrouping) { // Reset selects and ordering on the cloned base query $baseTotals = $baseTotalsQuery ->selectRaw( ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost' ) ->first(); if ($baseTotals !== null) { /** @var object{aggregate: int|null, cost: int|null} $baseTotals */ $group1ResponseSum = (int) ($baseTotals->aggregate ?? 0); $group1ResponseCost = (int) ($baseTotals->cost ?? 0); } } if ($fillGapsInTimeGroupsIsPossible) { $group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end); } } else { $group1Response = null; /** @var Collection $timeEntriesAggregates */ $group1ResponseSum = (int) $timeEntriesAggregates->get(0)->aggregate; $group1ResponseCost = (int) $timeEntriesAggregates->get(0)->cost; } return [ 'seconds' => $group1ResponseSum, 'cost' => $showBillableRate ? $group1ResponseCost : null, 'grouped_type' => $group1Type?->value, 'grouped_data' => $group1Response, ]; } /** * @param Builder $timeEntriesQuery * @return array{ * grouped_type: string|null, * grouped_data: null|array * }>, * seconds: int, * 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 { $aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes); $keysGroup1 = []; $keysGroup2 = []; if ($aggregatedTimeEntries['grouped_data'] !== null) { foreach ($aggregatedTimeEntries['grouped_data'] as $group1) { $keysGroup1[] = $group1['key']; if ($group1['grouped_data'] !== null) { foreach ($group1['grouped_data'] as $group2) { $keysGroup2[] = $group2['key']; } } } } $descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptorsMap($keysGroup1, $group1Type) : []; $descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptorsMap($keysGroup2, $group2Type) : []; if ($aggregatedTimeEntries['grouped_data'] !== null) { foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) { $aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['description'] ?? null) : null; $aggregatedTimeEntries['grouped_data'][$keyGroup1]['color'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['color'] ?? null) : null; if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) { foreach ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] as $keyGroup2 => $group2) { $aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['description'] ?? null) : null; $aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['color'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['color'] ?? null) : null; } } } } /** * @var array{ * grouped_type: string|null, * grouped_data: null|array * }>, * seconds: int, * cost: int * } $aggregatedTimeEntries */ return $aggregatedTimeEntries; } /** * @param array $keys * @return array */ private function loadDescriptorsMap(array $keys, TimeEntryAggregationType $type): array { $descriptorMap = []; if ($type === TimeEntryAggregationType::Client) { $clients = Client::query() ->whereIn('id', $keys) ->select('id', 'name') ->get(); foreach ($clients as $client) { $descriptorMap[$client->id] = [ 'description' => $client->name, 'color' => null, ]; } } elseif ($type === TimeEntryAggregationType::User) { $users = User::query() ->whereIn('id', $keys) ->select('id', 'name') ->get(); foreach ($users as $user) { $descriptorMap[$user->id] = [ 'description' => $user->name, 'color' => null, ]; } } elseif ($type === TimeEntryAggregationType::Project) { $projects = Project::query() ->whereIn('id', $keys) ->select('id', 'name', 'color') ->get(); foreach ($projects as $project) { $descriptorMap[$project->id] = [ 'description' => $project->name, 'color' => $project->color, ]; } } elseif ($type === TimeEntryAggregationType::Task) { $tasks = Task::query() ->whereIn('id', $keys) ->select('id', 'name') ->get(); foreach ($tasks as $task) { $descriptorMap[$task->id] = [ 'description' => $task->name, 'color' => null, ]; } } elseif ($type === TimeEntryAggregationType::Description) { foreach ($keys as $key) { $descriptorMap[$key] = [ 'description' => $key, 'color' => null, ]; } } elseif ($type === TimeEntryAggregationType::Billable) { foreach ($keys as $key) { $descriptorMap[$key] = [ 'description' => $key === '0' ? 'Non-billable' : 'Billable', 'color' => null, ]; } } elseif ($type === TimeEntryAggregationType::Tag) { $tags = Tag::query() ->whereIn('id', $keys) ->select('id', 'name') ->get(); foreach ($tags as $tag) { $descriptorMap[$tag->id] = [ 'description' => $tag->name, 'color' => null, ]; } } return $descriptorMap; } /** * @param array * }> $data * @return array * }> */ public function fillGapsInTimeGroups(array $data, TimeEntryAggregationType $groupType, ?TimeEntryAggregationType $subGroupType, string $timezone, Weekday $startOfWeek, Carbon $start, Carbon $end): array { $interval = $groupType->toInterval(); if ($interval === null) { foreach ($data as $key => $item) { $data[$key]['grouped_data'] = $this->fillGapsInTimeGroups( $item['grouped_data'], $subGroupType, null, $timezone, $startOfWeek, $start, $end ); } return $data; } else { $format = match ($interval) { TimeEntryAggregationTypeInterval::Day, TimeEntryAggregationTypeInterval::Week => 'Y-m-d', TimeEntryAggregationTypeInterval::Month => 'Y-m', TimeEntryAggregationTypeInterval::Year => 'Y', }; $slots = $this->timeSlotsBetween($start, $end, $timezone, $startOfWeek, $interval, $format); $foundEntries = []; $filledData = []; foreach ($slots as $slot) { $foundDataSet = null; foreach ($data as $item) { if ($item['key'] === $slot) { $foundDataSet = $item; $foundEntries[] = $item['key']; break; } } if ($foundDataSet !== null) { $filledData[] = [ 'key' => $slot, 'seconds' => $foundDataSet['seconds'], 'cost' => $foundDataSet['cost'], 'grouped_type' => $subGroupType?->value, 'grouped_data' => $subGroupType === null ? null : $this->fillGapsInTimeGroups( $foundDataSet['grouped_data'], $subGroupType, null, $timezone, $startOfWeek, $start, $end ), ]; } else { $filledData[] = [ 'key' => $slot, 'seconds' => 0, 'cost' => 0, 'grouped_type' => $subGroupType?->value, 'grouped_data' => $subGroupType === null ? null : [], ]; } } if (count($foundEntries) !== count($data)) { foreach ($data as $item) { if (! in_array($item['key'], $foundEntries, true)) { Log::error('Problem with filling gaps in time groups', [ 'item' => $item, ]); } } } return $filledData; } } private function getGroupByQuery(TimeEntryAggregationType $group, string $timezone, Weekday $startOfWeek): string { $timezoneShift = app(TimezoneService::class)->getShiftFromUtc(new CarbonTimeZone($timezone)); if ($timezoneShift > 0) { $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; } elseif ($timezoneShift < 0) { $dateWithTimeZone = 'start - INTERVAL \''.abs($timezoneShift).' second\''; } else { $dateWithTimeZone = 'start'; } $startOfWeek = Carbon::now()->setTimezone($timezone)->startOfWeek($startOfWeek->carbonWeekDay())->toDateTimeString(); if ($group === TimeEntryAggregationType::Day) { return 'date('.$dateWithTimeZone.')'; } elseif ($group === TimeEntryAggregationType::Week) { return "to_char(date_bin('7 days', ".$dateWithTimeZone.", timestamp '".$startOfWeek."'), 'YYYY-MM-DD')"; } elseif ($group === TimeEntryAggregationType::Month) { return 'to_char('.$dateWithTimeZone.', \'YYYY-MM\')'; } elseif ($group === TimeEntryAggregationType::Year) { return 'to_char('.$dateWithTimeZone.', \'YYYY\')'; } elseif ($group === TimeEntryAggregationType::User) { return 'user_id'; } elseif ($group === TimeEntryAggregationType::Project) { return 'project_id'; } elseif ($group === TimeEntryAggregationType::Task) { return 'task_id'; } elseif ($group === TimeEntryAggregationType::Client) { return 'client_id'; } elseif ($group === TimeEntryAggregationType::Billable) { return 'billable'; } elseif ($group === TimeEntryAggregationType::Description) { return 'description'; } elseif ($group === TimeEntryAggregationType::Tag) { return 'tag'; } } /** * @return Collection */ public function timeSlotsBetween(Carbon $start, Carbon $end, string $timezone, Weekday $startOfWeek, TimeEntryAggregationTypeInterval $interval, string $format): Collection { if ($start->gt($end)) { throw new \InvalidArgumentException('Start date must be before end date'); } $slots = new Collection; $current = $start->copy()->timezone($timezone); if ($interval === TimeEntryAggregationTypeInterval::Day) { $current->startOfDay(); } elseif ($interval === TimeEntryAggregationTypeInterval::Week) { $current->startOfWeek($startOfWeek->carbonWeekDay()); } elseif ($interval === TimeEntryAggregationTypeInterval::Month) { $current->startOfMonth(); } elseif ($interval === TimeEntryAggregationTypeInterval::Year) { $current->startOfYear(); } else { throw new \InvalidArgumentException('Invalid interval'); } while ($current->lt($end)) { $slots->push($current->format($format)); if ($interval === TimeEntryAggregationTypeInterval::Day) { $current->addDay(); } elseif ($interval === TimeEntryAggregationTypeInterval::Week) { $current->addWeek(); } elseif ($interval === TimeEntryAggregationTypeInterval::Month) { $current->addMonth(); } elseif ($interval === TimeEntryAggregationTypeInterval::Year) { $current->addYear(); } } return $slots; } } ================================================ FILE: app/Service/TimeEntryFilter.php ================================================ */ private Builder $builder; /** * @param Builder $builder */ public function __construct(Builder $builder) { $this->builder = $builder; } public function addEndFilter(?string $dateTime): self { if ($dateTime === null) { return $this; } $this->addEnd(Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); return $this; } public function addEnd(?Carbon $end): self { if ($end === null) { return $this; } $this->builder->where('start', '<', $end); return $this; } public function addStartFilter(?string $dateTime): self { if ($dateTime === null) { return $this; } $this->addStart(Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); return $this; } public function addStart(?Carbon $start): self { if ($start === null) { return $this; } $this->builder->where('start', '>', $start); return $this; } public function addActiveFilter(?string $active): self { if ($active === null) { return $this; } if ($active === 'true') { $this->addActive(true); } elseif ($active === 'false') { $this->addActive(false); } else { Log::warning('Invalid active filter value', ['value' => $active]); } return $this; } public function addActive(?bool $active): self { if ($active) { $this->builder->whereNull('end'); } else { $this->builder->whereNotNull('end'); } return $this; } public function addMemberIdFilter(?Member $member): self { if ($member === null) { return $this; } $this->builder->where('member_id', $member->getKey()); return $this; } /** * @param array|null $memberIds */ public function addMemberIdsFilter(?array $memberIds): self { if ($memberIds === null) { return $this; } $this->builder->whereIn('member_id', $memberIds); return $this; } public function addBillableFilter(?string $billable): self { if ($billable === null) { return $this; } if ($billable === 'true') { $this->addBillable(true); } elseif ($billable === 'false') { $this->addBillable(false); } else { Log::warning('Invalid billable filter value', ['value' => $billable]); } return $this; } public function addBillable(?bool $billable): self { if ($billable === null) { return $this; } $this->builder->where('billable', '=', $billable); return $this; } /** * @param array|null $clientIds */ public function addClientIdsFilter(?array $clientIds): self { if ($clientIds === null) { return $this; } $includeNone = in_array(self::NONE_VALUE, $clientIds, true); $clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE)); $this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void { if (count($clientIds) > 0) { $builder->whereIn('client_id', $clientIds); } if ($includeNone) { $builder->orWhereNull('client_id'); } }); return $this; } /** * @param array|null $projectIds */ public function addProjectIdsFilter(?array $projectIds): self { if ($projectIds === null) { return $this; } $includeNone = in_array(self::NONE_VALUE, $projectIds, true); $projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE)); $this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void { if (count($projectIds) > 0) { $builder->whereIn('project_id', $projectIds); } if ($includeNone) { $builder->orWhereNull('project_id'); } }); return $this; } /** * @param array|null $tagIds */ public function addTagIdsFilter(?array $tagIds): self { if ($tagIds === null) { return $this; } $includeNone = in_array(self::NONE_VALUE, $tagIds, true); $tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE)); $this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void { foreach ($tagIds as $tagId) { $builder->orWhereJsonContains('tags', $tagId); } if ($includeNone) { $builder->orWhere(function (Builder $query): void { $query->whereJsonLength('tags', 0)->orWhereNull('tags'); }); } }); return $this; } /** * @param array|null $taskIds */ public function addTaskIdsFilter(?array $taskIds): self { if ($taskIds === null) { return $this; } $includeNone = in_array(self::NONE_VALUE, $taskIds, true); $taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE)); $this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void { if (count($taskIds) > 0) { $builder->whereIn('task_id', $taskIds); } if ($includeNone) { $builder->orWhereNull('task_id'); } }); return $this; } /** * @return Builder */ public function get(): Builder { return $this->builder; } } ================================================ FILE: app/Service/TimeEntryService.php ================================================ toDateTimeString().'\')'; } if ($roundingMinutes < 1) { throw new LogicException('Rounding minutes must be greater than 0'); } $end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')'; $start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes); if ($roundingType === TimeEntryRoundingType::Down) { return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')'; } elseif ($roundingType === TimeEntryRoundingType::Up) { // If end is already on a boundary, keep it; otherwise round up to next boundary return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '. 'THEN '.$end.' '. 'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '. 'END'; } elseif ($roundingType === TimeEntryRoundingType::Nearest) { return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')'; } } } ================================================ FILE: app/Service/TimezoneService.php ================================================ 'Australia/Sydney', 'Australia/LHI' => 'Australia/Lord_Howe', 'Australia/NSW' => 'Australia/Sydney', 'Australia/North' => 'Australia/Darwin', 'Australia/Queensland' => 'Australia/Brisbane', 'Australia/South' => 'Australia/Adelaide', 'Australia/Tasmania' => 'Australia/Hobart', 'Australia/Victoria' => 'Australia/Melbourne', 'Australia/West' => 'Australia/Perth', 'Australia/Yancowinna' => 'Australia/Broken_Hill', 'Brazil/Acre' => 'America/Rio_Branco', 'Brazil/DeNoronha' => 'America/Noronha', 'Brazil/East' => 'America/Sao_Paulo', 'Brazil/West' => 'America/Manaus', 'CET' => 'Europe/Brussels', 'CST6CDT' => 'America/Chicago', 'Canada/Atlantic' => 'America/Halifax', 'Canada/Central' => 'America/Winnipeg', 'Canada/Eastern' => 'America/Toronto', 'Canada/Mountain' => 'America/Edmonton', 'Canada/Newfoundland' => 'America/St_Johns', 'Canada/Pacific' => 'America/Vancouver', 'Canada/Saskatchewan' => 'America/Regina', 'Canada/Yukon' => 'America/Whitehorse', 'Chile/Continental' => 'America/Santiago', 'Chile/EasterIsland' => 'Pacific/Easter', 'Cuba' => 'America/Havana', 'EET' => 'Europe/Athens', 'EST' => 'America/Panama', 'EST5EDT' => 'America/New_York', 'Egypt' => 'Africa/Cairo', 'Eire' => 'Europe/Dublin', 'Etc/GMT+0' => 'Etc/GMT ', 'Etc/GMT-0' => 'Etc/GMT ', 'Etc/GMT0' => 'Etc/GMT ', 'Etc/Greenwich' => 'Etc/GMT ', 'Etc/UCT' => 'Etc/UTC ', 'Etc/Universal' => 'Etc/UTC ', 'Etc/Zulu' => 'Etc/UTC ', 'GB' => 'Europe/London', 'GB-Eire' => 'Europe/London', 'GMT+0' => 'Etc/GMT ', 'GMT-0' => 'Etc/GMT ', 'GMT0' => 'Etc/GMT ', 'Greenwich' => 'Etc/GMT ', 'Hongkong' => 'Asia/Hong_Kong', 'Iceland' => 'Africa/Abidjan', 'Iran' => 'Asia/Tehran', 'Israel' => 'Asia/Jerusalem', 'Jamaica' => 'America/Jamaica', 'Japan' => 'Asia/Tokyo', 'Kwajalein' => 'Pacific/Kwajalein', 'Libya' => 'Africa/Tripoli', 'MET' => 'Europe/Brussels', 'MST' => 'America/Phoenix', 'MST7MDT' => 'America/Denver', 'Mexico/BajaNorte' => 'America/Tijuana', 'Mexico/BajaSur' => 'America/Mazatlan', 'Mexico/General' => 'America/Mexico_City', 'NZ' => 'Pacific/Auckland', 'NZ-CHAT' => 'Pacific/Chatham', 'Navajo' => 'America/Denver', 'PRC' => 'Asia/Shanghai', 'Poland' => 'Europe/Warsaw', 'Portugal' => 'Europe/Lisbon', 'ROC' => 'Asia/Taipei', 'ROK' => 'Asia/Seoul', 'Singapore' => 'Asia/Singapore', 'Turkey' => 'Europe/Istanbul', 'UCT' => 'Etc/UTC ', 'US/Alaska' => 'America/Anchorage', 'US/Aleutian' => 'America/Adak', 'US/Arizona' => 'America/Phoenix', 'US/Central' => 'America/Chicago', 'US/East-Indiana' => 'America/Indiana/Indianapolis', 'US/Eastern' => 'America/New_York', 'US/Hawaii' => 'Pacific/Honolulu', 'US/Indiana-Starke' => 'America/Indiana/Knox', 'US/Michigan' => 'America/Detroit', 'US/Mountain' => 'America/Denver', 'US/Pacific' => 'America/Los_Angeles', 'US/Samoa' => 'Pacific/Pago_Pago', 'UTC' => 'Etc/UTC ', 'Universal' => 'Etc/UTC ', 'W-SU' => 'Europe/Moscow', 'Zulu' => 'Etc/UTC ', 'America/Buenos_Aires' => 'America/Argentina/Buenos_Aires', 'America/Catamarca' => 'America/Argentina/Catamarca', 'America/Cordoba' => 'America/Argentina/Cordoba', 'America/Indianapolis' => 'America/Indiana/Indianapolis', 'America/Jujuy' => 'America/Argentina/Jujuy', 'America/Knox_IN' => 'America/Indiana/Knox', 'America/Louisville' => 'America/Kentucky/Louisville', 'America/Mendoza' => 'America/Argentina/Mendoza', 'America/Virgin' => 'America/Puerto_Rico', 'Pacific/Samoa' => 'Pacific/Pago_Pago', 'Africa/Accra' => 'Africa/Abidjan', 'Africa/Addis_Ababa' => 'Africa/Nairobi', 'Africa/Asmara' => 'Africa/Nairobi', 'Africa/Bamako' => 'Africa/Abidjan', 'Africa/Bangui' => 'Africa/Lagos', 'Africa/Banjul' => 'Africa/Abidjan', 'Africa/Blantyre' => 'Africa/Maputo', 'Africa/Brazzaville' => 'Africa/Lagos', 'Africa/Bujumbura' => 'Africa/Maputo', 'Africa/Conakry' => 'Africa/Abidjan', 'Africa/Dakar' => 'Africa/Abidjan', 'Africa/Dar_es_Salaam' => 'Africa/Nairobi', 'Africa/Djibouti' => 'Africa/Nairobi', 'Africa/Douala' => 'Africa/Lagos', 'Africa/Freetown' => 'Africa/Abidjan', 'Africa/Gaborone' => 'Africa/Maputo', 'Africa/Harare' => 'Africa/Maputo', 'Africa/Kampala' => 'Africa/Nairobi', 'Africa/Kigali' => 'Africa/Maputo', 'Africa/Kinshasa' => 'Africa/Lagos', 'Africa/Libreville' => 'Africa/Lagos', 'Africa/Lome' => 'Africa/Abidjan', 'Africa/Luanda' => 'Africa/Lagos', 'Africa/Lubumbashi' => 'Africa/Maputo', 'Africa/Lusaka' => 'Africa/Maputo', 'Africa/Malabo' => 'Africa/Lagos', 'Africa/Maseru' => 'Africa/Johannesburg', 'Africa/Mbabane' => 'Africa/Johannesburg', 'Africa/Mogadishu' => 'Africa/Nairobi', 'Africa/Niamey' => 'Africa/Lagos', 'Africa/Nouakchott' => 'Africa/Abidjan', 'Africa/Ouagadougou' => 'Africa/Abidjan', 'Africa/Porto-Novo' => 'Africa/Lagos', 'America/Anguilla' => 'America/Puerto_Rico', 'America/Antigua' => 'America/Puerto_Rico', 'America/Aruba' => 'America/Puerto_Rico', 'America/Atikokan' => 'America/Panama', 'America/Blanc-Sablon' => 'America/Puerto_Rico', 'America/Cayman' => 'America/Panama', 'America/Creston' => 'America/Phoenix', 'America/Curacao' => 'America/Puerto_Rico', 'America/Dominica' => 'America/Puerto_Rico', 'America/Grenada' => 'America/Puerto_Rico', 'America/Guadeloupe' => 'America/Puerto_Rico', 'America/Kralendijk' => 'America/Puerto_Rico', 'America/Lower_Princes' => 'America/Puerto_Rico', 'America/Marigot' => 'America/Puerto_Rico', 'America/Montserrat' => 'America/Puerto_Rico', 'America/Nassau' => 'America/Toronto', 'America/Port_of_Spain' => 'America/Puerto_Rico', 'America/St_Barthelemy' => 'America/Puerto_Rico', 'America/St_Kitts' => 'America/Puerto_Rico', 'America/St_Lucia' => 'America/Puerto_Rico', 'America/St_Thomas' => 'America/Puerto_Rico', 'America/St_Vincent' => 'America/Puerto_Rico', 'America/Tortola' => 'America/Puerto_Rico', 'Antarctica/DumontDUrville' => 'Pacific/Port_Moresby', 'Antarctica/McMurdo' => 'Pacific/Auckland', 'Antarctica/Syowa' => 'Asia/Riyadh', 'Arctic/Longyearbyen' => 'Europe/Berlin', 'Asia/Aden' => 'Asia/Riyadh', 'Asia/Bahrain' => 'Asia/Qatar', 'Asia/Brunei' => 'Asia/Kuching', 'Asia/Kuala_Lumpur' => 'Asia/Singapore', 'Asia/Kuwait' => 'Asia/Riyadh', 'Asia/Muscat' => 'Asia/Dubai', 'Asia/Phnom_Penh' => 'Asia/Bangkok', 'Asia/Vientiane' => 'Asia/Bangkok', 'Atlantic/Reykjavik' => 'Africa/Abidjan', 'Atlantic/St_Helena' => 'Africa/Abidjan', 'Europe/Amsterdam' => 'Europe/Brussels', 'Europe/Bratislava' => 'Europe/Prague', 'Europe/Busingen' => 'Europe/Zurich', 'Europe/Copenhagen' => 'Europe/Berlin', 'Europe/Guernsey' => 'Europe/London', 'Europe/Isle_of_Man' => 'Europe/London', 'Europe/Jersey' => 'Europe/London', 'Europe/Ljubljana' => 'Europe/Belgrade', 'Europe/Luxembourg' => 'Europe/Brussels', 'Europe/Mariehamn' => 'Europe/Helsinki', 'Europe/Monaco' => 'Europe/Paris', 'Europe/Oslo' => 'Europe/Berlin', 'Europe/Podgorica' => 'Europe/Belgrade', 'Europe/San_Marino' => 'Europe/Rome', 'Europe/Sarajevo' => 'Europe/Belgrade', 'Europe/Skopje' => 'Europe/Belgrade', 'Europe/Stockholm' => 'Europe/Berlin', 'Europe/Vaduz' => 'Europe/Zurich', 'Europe/Vatican' => 'Europe/Rome', 'Europe/Zagreb' => 'Europe/Belgrade', 'Indian/Antananarivo' => 'Africa/Nairobi', 'Indian/Christmas' => 'Asia/Bangkok', 'Indian/Cocos' => 'Asia/Yangon', 'Indian/Comoro' => 'Africa/Nairobi', 'Indian/Kerguelen' => 'Indian/Maldives', 'Indian/Mahe' => 'Asia/Dubai', 'Indian/Mayotte' => 'Africa/Nairobi', 'Indian/Reunion' => 'Asia/Dubai', 'Pacific/Chuuk' => 'Pacific/Port_Moresby', 'Pacific/Funafuti' => 'Pacific/Tarawa', 'Pacific/Majuro' => 'Pacific/Tarawa', 'Pacific/Midway' => 'Pacific/Pago_Pago', 'Pacific/Pohnpei' => 'Pacific/Guadalcanal', 'Pacific/Saipan' => 'Pacific/Guam', 'Pacific/Wake' => 'Pacific/Tarawa', 'Pacific/Wallis' => 'Pacific/Tarawa', 'Africa/Timbuktu' => 'Africa/Abidjan', 'America/Argentina/ComodRivadavia' => 'America/Argentina/Catamarca', 'America/Atka' => 'America/Adak', 'America/Coral_Harbour' => 'America/Panama', 'America/Ensenada' => 'America/Tijuana', 'America/Fort_Wayne' => 'America/Indiana/Indianapolis', 'America/Montreal' => 'America/Toronto', 'America/Nipigon' => 'America/Toronto', 'America/Pangnirtung' => 'America/Iqaluit', 'America/Porto_Acre' => 'America/Rio_Branco', 'America/Rainy_River' => 'America/Winnipeg', 'America/Rosario' => 'America/Argentina/Cordoba', 'America/Santa_Isabel' => 'America/Tijuana', 'America/Shiprock' => 'America/Denver', 'America/Thunder_Bay' => 'America/Toronto', 'America/Yellowknife' => 'America/Edmonton', 'Antarctica/South_Pole' => 'Pacific/Auckland', 'Asia/Choibalsan' => 'Asia/Ulaanbaatar', 'Asia/Chongqing' => 'Asia/Shanghai', 'Asia/Harbin' => 'Asia/Shanghai', 'Asia/Kashgar' => 'Asia/Urumqi', 'Asia/Tel_Aviv' => 'Asia/Jerusalem', 'Atlantic/Jan_Mayen' => 'Europe/Berlin', 'Australia/Canberra' => 'Australia/Sydney', 'Australia/Currie' => 'Australia/Hobart', 'Europe/Belfast' => 'Europe/London', 'Europe/Tiraspol' => 'Europe/Chisinau', 'Europe/Uzhgorod' => 'Europe/Kyiv', 'Europe/Zaporozhye' => 'Europe/Kyiv', 'Pacific/Enderbury' => 'Pacific/Kanton', 'Pacific/Johnston' => 'Pacific/Honolulu', 'Pacific/Yap' => 'Pacific/Port_Moresby', 'WET' => 'Europe/Lisbon', 'Africa/Asmera' => 'Africa/Nairobi', 'America/Godthab' => 'America/Nuuk', 'Asia/Ashkhabad' => 'Asia/Ashgabat', 'Asia/Calcutta' => 'Asia/Kolkata', 'Asia/Chungking' => 'Asia/Shanghai', 'Asia/Dacca' => 'Asia/Dhaka', 'Asia/Istanbul' => 'Europe/Istanbul', 'Asia/Katmandu' => 'Asia/Kathmandu', 'Asia/Macao' => 'Asia/Macau', 'Asia/Rangoon' => 'Asia/Yangon', 'Asia/Saigon' => 'Asia/Ho_Chi_Minh', 'Asia/Thimbu' => 'Asia/Thimphu', 'Asia/Ujung_Pandang' => 'Asia/Makassar', 'Asia/Ulan_Bator' => 'Asia/Ulaanbaatar', 'Atlantic/Faeroe' => 'Atlantic/Faroe', 'Europe/Kiev' => 'Europe/Kyiv', 'Europe/Nicosia' => 'Asia/Nicosia', 'HST' => 'Pacific/Honolulu', 'PST8PDT' => 'America/Los_Angeles', 'Pacific/Ponape' => 'Pacific/Guadalcanal', 'Pacific/Truk' => 'Pacific/Port_Moresby', ]; /** * @return array */ public function getTimezones(bool $inclLegacy = false): array { return $inclLegacy ? CarbonTimeZone::listIdentifiers(CarbonTimeZone::ALL_WITH_BC) : CarbonTimeZone::listIdentifiers(); } 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 */ public function getSelectOptions(): array { $tzlist = $this->getTimezones(); $options = []; foreach ($tzlist as $tz) { $options[$tz] = $tz; } return $options; } public function isValid(string $timezone): bool { return in_array($timezone, $this->getTimezones(), true); } public function mapLegacyTimezone(string $timezone): ?string { return self::LEGACY_TIMEZONES_MAP[$timezone] ?? null; } public function getShiftFromUtc(CarbonTimeZone $timeZone): int { return $timeZone->getOffset(Carbon::now()); } } ================================================ FILE: app/Service/UserService.php ================================================ name = $name; $user->email = $email; $user->password = Hash::make($password); $user->timezone = $timezone; $user->week_start = $weekStart; if ($verifyEmail) { $user->email_verified_at = Carbon::now(); } $user->save(); $organization = app(OrganizationService::class)->createOrganization( $this->getOrganizationNameForUserName($user->name), $user, true, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat, ); $user->ownedTeams()->save($organization); return $user; } /** * This does NOT change the member id. * This should only be used in if you want to change a member to a placeholder! */ public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void { // Time entries TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->whereBelongsTo($fromUser, 'user') ->update([ 'user_id' => $toUser->getKey(), ]); // Project members ProjectMember::query() ->whereBelongsToOrganization($organization) ->whereBelongsTo($fromUser, 'user') ->update([ 'user_id' => $toUser->getKey(), ]); } public function makeSureUserHasAtLeastOneOrganization(User $user): void { if ($user->organizations()->count() > 0) { return; } // Create a new organization $organization = app(OrganizationService::class)->createOrganization( $this->getOrganizationNameForUserName($user->name), $user, true ); // Set the organization as the user's current organization $user->currentOrganization()->associate($organization); $user->save(); AfterCreateOrganization::dispatch($organization); } public function getOrganizationNameForUserName(string $username): string { return explode(' ', $username, 2)[0]."'s Organization"; } public function makeSureUserHasCurrentOrganization(User $user): void { if ($user->current_team_id !== null) { return; } $organization = $user->organizations()->first(); if ($organization !== null) { $user->currentOrganization()->associate($organization); $user->save(); } } /** * Change the ownership of an organization to a new user. * The previous owner will be demoted to an admin. */ public function changeOwnership(Organization $organization, User $newOwner): void { $organization->update([ 'user_id' => $newOwner->getKey(), ]); /** @var Member|null $userMembership */ $userMembership = Member::query() ->whereBelongsTo($organization, 'organization') ->whereBelongsTo($newOwner, 'user') ->first(); if ($userMembership === null) { throw new \InvalidArgumentException('User is not a member of the organization'); } $userMembership->role = Role::Owner->value; $userMembership->save(); $oldOwners = Member::query() ->whereBelongsTo($organization, 'organization') ->where('role', '=', Role::Owner->value) ->where('user_id', '!=', $newOwner->getKey()) ->get(); foreach ($oldOwners as $oldOwner) { $oldOwner->role = Role::Admin->value; $oldOwner->save(); } } } ================================================ FILE: artisan ================================================ #!/usr/bin/env php make(Illuminate\Contracts\Console\Kernel::class); $status = $kernel->handle( $input = new Symfony\Component\Console\Input\ArgvInput, new Symfony\Component\Console\Output\ConsoleOutput ); /* |-------------------------------------------------------------------------- | Shutdown The Application |-------------------------------------------------------------------------- | | Once Artisan has finished running, we will fire off the shutdown events | so that any final work may be done by the application before we shut | down the process. This is the last thing to happen to the request. | */ $kernel->terminate($input, $status); exit($status); ================================================ FILE: bootstrap/app.php ================================================ singleton( Illuminate\Contracts\Http\Kernel::class, App\Http\Kernel::class ); $app->singleton( Illuminate\Contracts\Console\Kernel::class, App\Console\Kernel::class ); $app->singleton( Illuminate\Contracts\Debug\ExceptionHandler::class, App\Exceptions\Handler::class ); /* |-------------------------------------------------------------------------- | Return The Application |-------------------------------------------------------------------------- | | This script returns the application instance. The instance is given to | the calling script so we can separate the building of the instances | from the actual running of the application and sending responses. | */ return $app; ================================================ FILE: bootstrap/cache/.gitignore ================================================ * !.gitignore ================================================ FILE: components.json ================================================ { "$schema": "https://shadcn-vue.com/schema.json", "style": "new-york", "typescript": true, "tailwind": { "config": "tailwind.config.js", "css": "resources/css/app.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "composables": "@/composables", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib" }, "iconLibrary": "lucide" } ================================================ FILE: composer.json ================================================ { "name": "solidtime-io/solidtime", "type": "project", "description": "An open-source time-tracking app", "keywords": [], "license": "AGPL-3.0-or-later", "require": { "php": "8.3.*", "ext-zip": "*", "brick/money": "^0.10.0", "datomatic/laravel-enum-helper": "^2.0.0", "dedoc/scramble": "^0.12.2", "filament/filament": "^3.2", "flowframe/laravel-trend": "^0.4.0", "gotenberg/gotenberg-php": "^2.8", "guzzlehttp/guzzle": "^7.2", "inertiajs/inertia-laravel": "^2.0.3", "korridor/laravel-computed-attributes": "^3.1", "korridor/laravel-has-many-sync": "^3.1", "korridor/laravel-model-validation-rules": "^3.0", "laravel/framework": "^12.19.3", "laravel/jetstream": "^5.0", "laravel/octane": "^2.3", "laravel/passport": "^13.0.5", "laravel/tinker": "^2.8", "league/csv": "^9.16.0", "league/flysystem-aws-s3-v3": "^3.0", "league/iso3166": "^4.3", "maatwebsite/excel": "^3.1", "novadaemon/filament-pretty-json": "^2.2", "nwidart/laravel-modules": "^12.0.4", "owen-it/laravel-auditing": "^14.0.0", "pxlrbt/filament-environment-indicator": "^2.1.0", "spatie/temporary-directory": "^2.2", "staudenmeir/eloquent-json-relations": "^1.1", "stechstudio/filament-impersonate": "^3.8", "tightenco/ziggy": "^2.1.0", "tpetry/laravel-postgresql-enhanced": "^3.0.0", "wikimedia/composer-merge-plugin": "^2.1.0" }, "require-dev": { "barryvdh/laravel-ide-helper": "^3.0", "brianium/paratest": "^7.3", "fakerphp/faker": "^1.9.1", "fumeapp/modeltyper": "^3.0", "larastan/larastan": "^3.5.0", "laravel/pint": "^1.0", "laravel/sail": "^1.18", "laravel/telescope": "^5.0", "mockery/mockery": "^1.4.4", "nunomaduro/collision": "^8.1", "phpunit/phpunit": "^12", "spatie/laravel-ignition": "^2.0", "timacdonald/log-fake": "^2.1" }, "autoload": { "psr-4": { "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" }, "files": [ "extensions/extensions_autoload.php" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi", "@php artisan filament:upgrade" ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ], "analyse": [ "@php ./vendor/bin/phpstan analyse --memory-limit=2G --configuration=phpstan.neon" ], "generate-typescript": [ "@php artisan model:typer > ./resources/js/types/models.ts" ], "ptest": [ "@php artisan test --parallel --stop-on-failure" ], "test": [ "@php artisan test --stop-on-failure" ], "test:coverage": [ "@php artisan test --coverage --stop-on-failure" ], "test:coverage:report": [ "@php vendor/bin/phpunit --coverage-html=coverage" ], "coverage-report": [ "@test:coverage:report" ], "fix": [ "@php pint" ], "ide-helper": [ "@php artisan ide-helper:generate", "@php artisan ide-helper:meta" ], "refresh-schema-dump": [ "@php artisan schema:dump --database=\"pgsql_test\"" ] }, "extra": { "laravel": { "dont-discover": [ "laravel/telescope", "nwidart/laravel-modules" ] } }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true, "php-http/discovery": true, "wikimedia/composer-merge-plugin": true } }, "minimum-stability": "stable", "prefer-stable": true } ================================================ FILE: config/app.php ================================================ 'solidtime', 'version' => env('APP_VERSION'), 'build' => env('APP_BUILD'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => (bool) env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. | */ 'url' => env('APP_URL', 'http://localhost'), 'asset_url' => env('ASSET_URL'), 'force_https' => (bool) env('APP_FORCE_HTTPS', false), 'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => 'en', /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', 'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15), /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', 'localization' => [ 'default_currency' => env('LOCALIZATION_DEFAULT_CURRENCY', 'EUR'), 'default_number_format' => env('LOCALIZATION_DEFAULT_NUMBER_FORMAT', NumberFormat::ThousandsPointDecimalComma->value), 'default_currency_format' => env('LOCALIZATION_DEFAULT_CURRENCY_FORMAT', CurrencyFormat::ISOCodeAfterWithSpace->value), 'default_date_format' => env('LOCALIZATION_DEFAULT_DATE_FORMAT', DateFormat::HyphenSeparatedYYYYMMDD->value), 'default_time_format' => env('LOCALIZATION_DEFAULT_TIME_FORMAT', TimeFormat::TwentyFourHours->value), 'default_interval_format' => env('LOCALIZATION_DEFAULT_INTERVAL_FORMAT', IntervalFormat::HoursMinutes->value), ], /* |-------------------------------------------------------------------------- | Maintenance Mode Driver |-------------------------------------------------------------------------- | | These configuration options determine the driver used to determine and | manage Laravel's "maintenance mode" status. The "cache" driver will | allow maintenance mode to be controlled across multiple machines. | | Supported drivers: "file", "cache" | */ 'maintenance' => [ 'driver' => 'file', // 'store' => 'redis', ], /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => ServiceProvider::defaultProviders()->merge([ /* * Package Service Providers... */ /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\Filament\AdminPanelProvider::class, App\Providers\RouteServiceProvider::class, App\Providers\FortifyServiceProvider::class, App\Providers\JetstreamServiceProvider::class, // Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider LaravelModulesServiceProvider::class, ])->toArray(), /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => Facade::defaultAliases()->merge([ // 'Example' => App\Facades\Example::class, ])->toArray(), ]; ================================================ FILE: config/audit.php ================================================ env('AUDITING_ENABLED', false), /* |-------------------------------------------------------------------------- | Audit Implementation |-------------------------------------------------------------------------- | | Define which Audit model implementation should be used. | */ 'implementation' => OwenIt\Auditing\Models\Audit::class, /* |-------------------------------------------------------------------------- | User Morph prefix & Guards |-------------------------------------------------------------------------- | | Define the morph prefix and authentication guards for the User resolver. | */ 'user' => [ 'morph_prefix' => 'user', 'guards' => [ 'web', 'api', ], 'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class, ], /* |-------------------------------------------------------------------------- | Audit Resolvers |-------------------------------------------------------------------------- | | Define the IP Address, User Agent and URL resolver implementations. | */ 'resolvers' => [ 'ip_address' => App\Extensions\Auditing\Resolvers\CustomIpAddressResolver::class, 'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class, 'url' => OwenIt\Auditing\Resolvers\UrlResolver::class, ], /* |-------------------------------------------------------------------------- | Audit Events |-------------------------------------------------------------------------- | | The Eloquent events that trigger an Audit. | */ 'events' => [ 'created', 'updated', 'deleted', 'restored', ], /* |-------------------------------------------------------------------------- | Strict Mode |-------------------------------------------------------------------------- | | Enable the strict mode when auditing? | */ 'strict' => true, /* |-------------------------------------------------------------------------- | Global exclude |-------------------------------------------------------------------------- | | Have something you always want to exclude by default? - add it here. | Note that this is overwritten (not merged) with local exclude | */ 'exclude' => [], /* |-------------------------------------------------------------------------- | Empty Values |-------------------------------------------------------------------------- | | Should Audit records be stored when the recorded old_values & new_values | are both empty? | | Some events may be empty on purpose. Use allowed_empty_values to exclude | those from the empty values check. For example when auditing | model retrieved events which will never have new and old values. | | */ 'empty_values' => false, 'allowed_empty_values' => [ 'retrieved', ], /* |-------------------------------------------------------------------------- | Allowed Array Values |-------------------------------------------------------------------------- | | Should the array values be audited? | | By default, array values are not allowed. This is to prevent performance | issues when storing large amounts of data. You can override this by | setting allow_array_values to true. */ 'allowed_array_values' => true, /* |-------------------------------------------------------------------------- | Audit Timestamps |-------------------------------------------------------------------------- | | Should the created_at, updated_at and deleted_at timestamps be audited? | */ 'timestamps' => false, /* |-------------------------------------------------------------------------- | Audit Threshold |-------------------------------------------------------------------------- | | Specify a threshold for the amount of Audit records a model can have. | Zero means no limit. | */ 'threshold' => 0, /* |-------------------------------------------------------------------------- | Audit Driver |-------------------------------------------------------------------------- | | The default audit driver used to keep track of changes. | */ 'driver' => 'database', /* |-------------------------------------------------------------------------- | Audit Driver Configurations |-------------------------------------------------------------------------- | | Available audit drivers and respective configurations. | */ 'drivers' => [ 'database' => [ 'table' => 'audits', 'connection' => null, ], ], /* |-------------------------------------------------------------------------- | Audit Queue Configurations |-------------------------------------------------------------------------- | | Available audit queue configurations. | */ 'queue' => [ 'enable' => false, 'connection' => 'sync', 'queue' => 'default', 'delay' => 0, ], /* |-------------------------------------------------------------------------- | Audit Console |-------------------------------------------------------------------------- | | Whether console events should be audited (eg. php artisan db:seed). | */ 'console' => true, ]; ================================================ FILE: config/auth.php ================================================ [ 'guard' => 'web', 'passwords' => 'users', ], /* |-------------------------------------------------------------------------- | Authentication Guards |-------------------------------------------------------------------------- | | Next, you may define every authentication guard for your application. | Of course, a great default configuration has been defined for you | here which uses session storage and the Eloquent user provider. | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | Supported: "session" | */ 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'passport', 'provider' => 'users', ], ], /* |-------------------------------------------------------------------------- | User Providers |-------------------------------------------------------------------------- | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | If you have multiple user tables or models you may configure multiple | sources which represent each model / table. These sources may then | be assigned to any extra authentication guards you have defined. | | Supported: "database", "eloquent" | */ 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => App\Models\User::class, ], ], /* |-------------------------------------------------------------------------- | Resetting Passwords |-------------------------------------------------------------------------- | | You may specify multiple password reset configurations if you have more | than one user table or model in the application and you want to have | separate password reset settings based on the specific user types. | | The expiry time is the number of minutes that each reset token will be | considered valid. This security feature keeps tokens short-lived so | they have less time to be guessed. You may change this as needed. | | The throttle setting is the number of seconds a user must wait before | generating more password reset tokens. This prevents the user from | quickly generating a very large amount of password reset tokens. | */ 'passwords' => [ 'users' => [ 'provider' => 'users', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 60, ], ], /* |-------------------------------------------------------------------------- | Password Confirmation Timeout |-------------------------------------------------------------------------- | | Here you may define the amount of seconds before a password confirmation | times out and the user is prompted to re-enter their password via the | confirmation screen. By default, the timeout lasts for three hours. | */ 'password_timeout' => 10800, 'super_admins' => ! is_string(env('SUPER_ADMINS', null)) ? [] : explode(',', env('SUPER_ADMINS')), 'terms_url' => env('TERMS_URL', ''), 'privacy_policy_url' => env('PRIVACY_POLICY_URL', ''), 'newsletter_consent' => env('NEWSLETTER_CONSENT', false), ]; ================================================ FILE: config/broadcasting.php ================================================ env('BROADCAST_DRIVER', 'null'), /* |-------------------------------------------------------------------------- | Broadcast Connections |-------------------------------------------------------------------------- | | Here you may define all of the broadcast connections that will be used | to broadcast events to other systems or over websockets. Samples of | each available type of connection are provided inside this array. | */ 'connections' => [ 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', 'port' => env('PUSHER_PORT', 443), 'scheme' => env('PUSHER_SCHEME', 'https'), 'encrypted' => true, 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', ], 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html ], ], 'ably' => [ 'driver' => 'ably', 'key' => env('ABLY_KEY'), ], 'redis' => [ 'driver' => 'redis', 'connection' => 'default', ], 'log' => [ 'driver' => 'log', ], 'null' => [ 'driver' => 'null', ], ], ]; ================================================ FILE: config/cache.php ================================================ env('CACHE_DRIVER', 'file'), /* |-------------------------------------------------------------------------- | Cache Stores |-------------------------------------------------------------------------- | | Here you may define all of the cache "stores" for your application as | well as their drivers. You may even define multiple stores for the | same cache driver to group types of items stored in your caches. | | Supported drivers: "apc", "array", "database", "file", | "memcached", "redis", "dynamodb", "octane", "null" | */ 'stores' => [ 'apc' => [ 'driver' => 'apc', ], 'array' => [ 'driver' => 'array', 'serialize' => false, ], 'database' => [ 'driver' => 'database', 'table' => 'cache', 'connection' => null, 'lock_connection' => null, ], 'file' => [ 'driver' => 'file', 'path' => storage_path('framework/cache/data'), 'lock_path' => storage_path('framework/cache/data'), ], 'memcached' => [ 'driver' => 'memcached', 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 'sasl' => [ env('MEMCACHED_USERNAME'), env('MEMCACHED_PASSWORD'), ], 'options' => [ // Memcached::OPT_CONNECT_TIMEOUT => 2000, ], 'servers' => [ [ 'host' => env('MEMCACHED_HOST', '127.0.0.1'), 'port' => env('MEMCACHED_PORT', 11211), 'weight' => 100, ], ], ], 'redis' => [ 'driver' => 'redis', 'connection' => 'cache', 'lock_connection' => 'default', ], 'dynamodb' => [ 'driver' => 'dynamodb', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 'endpoint' => env('DYNAMODB_ENDPOINT'), ], 'octane' => [ 'driver' => 'octane', ], ], /* |-------------------------------------------------------------------------- | Cache Key Prefix |-------------------------------------------------------------------------- | | When utilizing the APC, database, memcached, Redis, or DynamoDB cache | stores there might be other applications using the same cache. For | that reason, you may prefix every cache key to avoid collisions. | */ 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), ]; ================================================ FILE: config/cors.php ================================================ ['api/*', 'sanctum/csrf-cookie'], 'allowed_methods' => ['*'], 'allowed_origins' => ['*'], 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], 'exposed_headers' => [], 'max_age' => 0, 'supports_credentials' => false, ]; ================================================ FILE: config/database.php ================================================ env('DB_CONNECTION', 'mysql'), /* |-------------------------------------------------------------------------- | Database Connections |-------------------------------------------------------------------------- | | Here are each of the database connections setup for your application. | Of course, examples of configuring each database platform that is | supported by Laravel is shown below to make development simple. | | | All database work in Laravel is done through the PHP PDO facilities | so make sure you have the driver for your particular database of | choice installed on your machine before you begin development. | */ 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DATABASE_URL'), 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], 'mysql' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', ], 'pgsql_test' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), 'host' => env('DB_TEST_HOST', '127.0.0.1'), 'port' => env('DB_TEST_PORT', '5432'), 'database' => env('DB_TEST_DATABASE', 'forge'), 'username' => env('DB_TEST_USERNAME', 'forge'), 'password' => env('DB_TEST_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', ], 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', 'localhost'), 'port' => env('DB_PORT', '1433'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, // 'encrypt' => env('DB_ENCRYPT', 'yes'), // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), ], ], /* |-------------------------------------------------------------------------- | Migration Repository Table |-------------------------------------------------------------------------- | | This table keeps track of all the migrations that have already run for | your application. Using this information, we can determine which of | the migrations on disk haven't actually been run in the database. | */ 'migrations' => 'migrations', /* |-------------------------------------------------------------------------- | Redis Databases |-------------------------------------------------------------------------- | | Redis is an open source, fast, and advanced key-value store that also | provides a richer body of commands than a typical key-value system | such as APC or Memcached. Laravel makes it easy to dig right in. | */ 'redis' => [ 'client' => env('REDIS_CLIENT', 'phpredis'), 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'redis'), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), ], 'default' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_DB', '0'), ], 'cache' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_CACHE_DB', '1'), ], ], ]; ================================================ FILE: config/excel.php ================================================ [ /* |-------------------------------------------------------------------------- | Chunk size |-------------------------------------------------------------------------- | | When using FromQuery, the query is automatically chunked. | Here you can specify how big the chunk should be. | */ 'chunk_size' => 1000, /* |-------------------------------------------------------------------------- | Pre-calculate formulas during export |-------------------------------------------------------------------------- */ 'pre_calculate_formulas' => false, /* |-------------------------------------------------------------------------- | Enable strict null comparison |-------------------------------------------------------------------------- | | When enabling strict null comparison empty cells ('') will | be added to the sheet. */ 'strict_null_comparison' => false, /* |-------------------------------------------------------------------------- | CSV Settings |-------------------------------------------------------------------------- | | Configure e.g. delimiter, enclosure and line ending for CSV exports. | */ 'csv' => [ 'delimiter' => ',', 'enclosure' => '"', 'line_ending' => PHP_EOL, 'use_bom' => false, 'include_separator_line' => false, 'excel_compatibility' => false, 'output_encoding' => '', 'test_auto_detect' => true, ], /* |-------------------------------------------------------------------------- | Worksheet properties |-------------------------------------------------------------------------- | | Configure e.g. default title, creator, subject,... | */ 'properties' => [ 'creator' => '', 'lastModifiedBy' => '', 'title' => '', 'description' => '', 'subject' => '', 'keywords' => '', 'category' => '', 'manager' => '', 'company' => '', ], ], 'imports' => [ /* |-------------------------------------------------------------------------- | Read Only |-------------------------------------------------------------------------- | | When dealing with imports, you might only be interested in the | data that the sheet exists. By default we ignore all styles, | however if you want to do some logic based on style data | you can enable it by setting read_only to false. | */ 'read_only' => true, /* |-------------------------------------------------------------------------- | Ignore Empty |-------------------------------------------------------------------------- | | When dealing with imports, you might be interested in ignoring | rows that have null values or empty strings. By default rows | containing empty strings or empty values are not ignored but can be | ignored by enabling the setting ignore_empty to true. | */ 'ignore_empty' => false, /* |-------------------------------------------------------------------------- | Heading Row Formatter |-------------------------------------------------------------------------- | | Configure the heading row formatter. | Available options: none|slug|custom | */ 'heading_row' => [ 'formatter' => 'slug', ], /* |-------------------------------------------------------------------------- | CSV Settings |-------------------------------------------------------------------------- | | Configure e.g. delimiter, enclosure and line ending for CSV imports. | */ 'csv' => [ 'delimiter' => null, 'enclosure' => '"', 'escape_character' => '\\', 'contiguous' => false, 'input_encoding' => Csv::GUESS_ENCODING, ], /* |-------------------------------------------------------------------------- | Worksheet properties |-------------------------------------------------------------------------- | | Configure e.g. default title, creator, subject,... | */ 'properties' => [ 'creator' => '', 'lastModifiedBy' => '', 'title' => '', 'description' => '', 'subject' => '', 'keywords' => '', 'category' => '', 'manager' => '', 'company' => '', ], /* |-------------------------------------------------------------------------- | Cell Middleware |-------------------------------------------------------------------------- | | Configure middleware that is executed on getting a cell value | */ 'cells' => [ 'middleware' => [ // \Maatwebsite\Excel\Middleware\TrimCellValue::class, // \Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class, ], ], ], /* |-------------------------------------------------------------------------- | Extension detector |-------------------------------------------------------------------------- | | Configure here which writer/reader type should be used when the package | needs to guess the correct type based on the extension alone. | */ 'extension_detector' => [ 'xlsx' => Excel::XLSX, 'xlsm' => Excel::XLSX, 'xltx' => Excel::XLSX, 'xltm' => Excel::XLSX, 'xls' => Excel::XLS, 'xlt' => Excel::XLS, 'ods' => Excel::ODS, 'ots' => Excel::ODS, 'slk' => Excel::SLK, 'xml' => Excel::XML, 'gnumeric' => Excel::GNUMERIC, 'htm' => Excel::HTML, 'html' => Excel::HTML, 'csv' => Excel::CSV, 'tsv' => Excel::TSV, /* |-------------------------------------------------------------------------- | PDF Extension |-------------------------------------------------------------------------- | | Configure here which Pdf driver should be used by default. | Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF | */ 'pdf' => Excel::DOMPDF, ], /* |-------------------------------------------------------------------------- | Value Binder |-------------------------------------------------------------------------- | | PhpSpreadsheet offers a way to hook into the process of a value being | written to a cell. In there some assumptions are made on how the | value should be formatted. If you want to change those defaults, | you can implement your own default value binder. | | Possible value binders: | | [x] Maatwebsite\Excel\DefaultValueBinder::class | [x] PhpOffice\PhpSpreadsheet\Cell\StringValueBinder::class | [x] PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder::class | */ 'value_binder' => [ 'default' => Maatwebsite\Excel\DefaultValueBinder::class, ], 'cache' => [ /* |-------------------------------------------------------------------------- | Default cell caching driver |-------------------------------------------------------------------------- | | By default PhpSpreadsheet keeps all cell values in memory, however when | dealing with large files, this might result into memory issues. If you | want to mitigate that, you can configure a cell caching driver here. | When using the illuminate driver, it will store each value in the | cache store. This can slow down the process, because it needs to | store each value. You can use the "batch" store if you want to | only persist to the store when the memory limit is reached. | | Drivers: memory|illuminate|batch | */ 'driver' => 'memory', /* |-------------------------------------------------------------------------- | Batch memory caching |-------------------------------------------------------------------------- | | When dealing with the "batch" caching driver, it will only | persist to the store when the memory limit is reached. | Here you can tweak the memory limit to your liking. | */ 'batch' => [ 'memory_limit' => 60000, ], /* |-------------------------------------------------------------------------- | Illuminate cache |-------------------------------------------------------------------------- | | When using the "illuminate" caching driver, it will automatically use | your default cache store. However if you prefer to have the cell | cache on a separate store, you can configure the store name here. | You can use any store defined in your cache config. When leaving | at "null" it will use the default store. | */ 'illuminate' => [ 'store' => null, ], /* |-------------------------------------------------------------------------- | Cache Time-to-live (TTL) |-------------------------------------------------------------------------- | | The TTL of items written to cache. If you want to keep the items cached | indefinitely, set this to null. Otherwise, set a number of seconds, | a \DateInterval, or a callable. | | Allowable types: callable|\DateInterval|int|null | */ 'default_ttl' => 10800, ], /* |-------------------------------------------------------------------------- | Transaction Handler |-------------------------------------------------------------------------- | | By default the import is wrapped in a transaction. This is useful | for when an import may fail and you want to retry it. With the | transactions, the previous import gets rolled-back. | | You can disable the transaction handler by setting this to null. | Or you can choose a custom made transaction handler here. | | Supported handlers: null|db | */ 'transactions' => [ 'handler' => 'db', 'db' => [ 'connection' => null, ], ], 'temporary_files' => [ /* |-------------------------------------------------------------------------- | Local Temporary Path |-------------------------------------------------------------------------- | | When exporting and importing files, we use a temporary file, before | storing reading or downloading. Here you can customize that path. | permissions is an array with the permission flags for the directory (dir) | and the create file (file). | */ 'local_path' => storage_path('framework/cache/laravel-excel'), /* |-------------------------------------------------------------------------- | Local Temporary Path Permissions |-------------------------------------------------------------------------- | | Permissions is an array with the permission flags for the directory (dir) | and the create file (file). | If omitted the default permissions of the filesystem will be used. | */ 'local_permissions' => [ // 'dir' => 0755, // 'file' => 0644, ], /* |-------------------------------------------------------------------------- | Remote Temporary Disk |-------------------------------------------------------------------------- | | When dealing with a multi server setup with queues in which you | cannot rely on having a shared local temporary path, you might | want to store the temporary file on a shared disk. During the | queue executing, we'll retrieve the temporary file from that | location instead. When left to null, it will always use | the local path. This setting only has effect when using | in conjunction with queued imports and exports. | */ 'remote_disk' => null, 'remote_prefix' => null, /* |-------------------------------------------------------------------------- | Force Resync |-------------------------------------------------------------------------- | | When dealing with a multi server setup as above, it's possible | for the clean up that occurs after entire queue has been run to only | cleanup the server that the last AfterImportJob runs on. The rest of the server | would still have the local temporary file stored on it. In this case your | local storage limits can be exceeded and future imports won't be processed. | To mitigate this you can set this config value to be true, so that after every | queued chunk is processed the local temporary file is deleted on the server that | processed it. | */ 'force_resync_remote' => null, ], ]; ================================================ FILE: config/filament.php ================================================ [ // 'echo' => [ // 'broadcaster' => 'pusher', // 'key' => env('VITE_PUSHER_APP_KEY'), // 'cluster' => env('VITE_PUSHER_APP_CLUSTER'), // 'wsHost' => env('VITE_PUSHER_HOST'), // 'wsPort' => env('VITE_PUSHER_PORT'), // 'wssPort' => env('VITE_PUSHER_PORT'), // 'authEndpoint' => '/api/v1/broadcasting/auth', // 'disableStats' => true, // 'encrypted' => true, // ], ], /* |-------------------------------------------------------------------------- | Default Filesystem Disk |-------------------------------------------------------------------------- | | This is the storage disk Filament will use to put media. You may use any | of the disks defined in the `config/filesystems.php`. | */ 'default_filesystem_disk' => env('FILAMENT_FILESYSTEM_DISK', 'public'), /* |-------------------------------------------------------------------------- | Assets Path |-------------------------------------------------------------------------- | | This is the directory where Filament's assets will be published to. It | is relative to the `public` directory of your Laravel application. | | After changing the path, you should run `php artisan filament:assets`. | */ 'assets_path' => null, /* |-------------------------------------------------------------------------- | Livewire Loading Delay |-------------------------------------------------------------------------- | | This sets the delay before loading indicators appear. | | Setting this to 'none' makes indicators appear immediately, which can be | desirable for high-latency connections. Setting it to 'default' applies | Livewire's standard 200ms delay. | */ 'livewire_loading_delay' => 'default', ]; ================================================ FILE: config/filesystems.php ================================================ env('FILESYSTEM_DISK', 'local'), 'public' => env('PUBLIC_FILESYSTEM_DISK', 'public'), 'private' => env('FILESYSTEM_DISK', 'local'), /* |-------------------------------------------------------------------------- | Filesystem Disks |-------------------------------------------------------------------------- | | Here you may configure as many filesystem "disks" as you wish, and you | may even configure multiple disks of the same driver. Defaults have | been set up for each driver as an example of the required values. | | Supported Drivers: "local", "ftp", "sftp", "s3" | */ 'disks' => [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app'), 'serve' => true, 'throw' => true, ], 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL').'/storage', 'visibility' => 'public', 'throw' => true, ], 's3' => [ 'driver' => 's3', 'key' => env('S3_ACCESS_KEY_ID'), 'secret' => env('S3_SECRET_ACCESS_KEY'), 'region' => env('S3_REGION'), 'bucket' => env('S3_BUCKET'), 'url' => env('S3_URL'), 'temporary_url' => env('S3_URL'), 'endpoint' => env('S3_ENDPOINT'), 'use_path_style_endpoint' => env('S3_USE_PATH_STYLE_ENDPOINT', false), 'throw' => true, ], 'testfiles' => [ 'driver' => 'local', 'root' => resource_path('testfiles'), 'throw' => true, ], ], /* |-------------------------------------------------------------------------- | Symbolic Links |-------------------------------------------------------------------------- | | Here you may configure the symbolic links that will be created when the | `storage:link` Artisan command is executed. The array keys should be | the locations of the links and the values should be their targets. | */ 'links' => [ public_path('storage') => storage_path('app/public'), ], ]; ================================================ FILE: config/fortify.php ================================================ 'web', /* |-------------------------------------------------------------------------- | Fortify Password Broker |-------------------------------------------------------------------------- | | Here you may specify which password broker Fortify can use when a user | is resetting their password. This configured value should match one | of your password brokers setup in your "auth" configuration file. | */ 'passwords' => 'users', /* |-------------------------------------------------------------------------- | Username / Email |-------------------------------------------------------------------------- | | This value defines which model attribute should be considered as your | application's "username" field. Typically, this might be the email | address of the users but you are free to change this value here. | | Out of the box, Fortify expects forgot password and reset password | requests to have a field named 'email'. If the application uses | another name for the field you may define it below as needed. | */ 'username' => 'email', 'email' => 'email', /* |-------------------------------------------------------------------------- | Lowercase Usernames |-------------------------------------------------------------------------- | | This value defines whether usernames should be lowercased before saving | them in the database, as some database system string fields are case | sensitive. You may disable this for your application if necessary. | */ 'lowercase_usernames' => true, /* |-------------------------------------------------------------------------- | Home Path |-------------------------------------------------------------------------- | | Here you may configure the path where users will get redirected during | authentication or password reset when the operations are successful | and the user is authenticated. You are free to change this value. | */ 'home' => RouteServiceProvider::HOME, /* |-------------------------------------------------------------------------- | Fortify Routes Prefix / Subdomain |-------------------------------------------------------------------------- | | Here you may specify which prefix Fortify will assign to all the routes | that it registers with the application. If necessary, you may change | subdomain under which all of the Fortify routes will be available. | */ 'prefix' => '', 'domain' => null, /* |-------------------------------------------------------------------------- | Fortify Routes Middleware |-------------------------------------------------------------------------- | | Here you may specify which middleware Fortify will assign to the routes | that it registers with the application. If necessary, you may change | these middleware but typically this provided default is preferred. | */ 'middleware' => ['web'], /* |-------------------------------------------------------------------------- | Rate Limiting |-------------------------------------------------------------------------- | | By default, Fortify will throttle logins to five requests per minute for | every email and IP address combination. However, if you would like to | specify a custom rate limiter to call then you may specify it here. | */ 'limiters' => [ 'login' => 'login', 'two-factor' => 'two-factor', ], /* |-------------------------------------------------------------------------- | Register View Routes |-------------------------------------------------------------------------- | | Here you may specify if the routes returning views should be disabled as | you may not need them when building your own application. This may be | especially true if you're writing a custom single-page application. | */ 'views' => true, /* |-------------------------------------------------------------------------- | Features |-------------------------------------------------------------------------- | | Some of the Fortify features are optional. You may disable the features | by removing them from this array. You're free to only remove some of | these features or you can even remove all of these if you need to. | */ 'features' => [ Features::registration(), Features::resetPasswords(), Features::emailVerification(), Features::updateProfileInformation(), Features::updatePasswords(), Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, // 'window' => 0, ]), ], ]; ================================================ FILE: config/hashing.php ================================================ 'bcrypt', /* |-------------------------------------------------------------------------- | Bcrypt Options |-------------------------------------------------------------------------- | | Here you may specify the configuration options that should be used when | passwords are hashed using the Bcrypt algorithm. This will allow you | to control the amount of time it takes to hash the given password. | */ 'bcrypt' => [ 'rounds' => env('BCRYPT_ROUNDS', 12), 'verify' => true, ], /* |-------------------------------------------------------------------------- | Argon Options |-------------------------------------------------------------------------- | | Here you may specify the configuration options that should be used when | passwords are hashed using the Argon algorithm. These will allow you | to control the amount of time it takes to hash the given password. | */ 'argon' => [ 'memory' => 65536, 'threads' => 1, 'time' => 4, 'verify' => true, ], ]; ================================================ FILE: config/jetstream.php ================================================ 'inertia', /* |-------------------------------------------------------------------------- | Jetstream Route Middleware |-------------------------------------------------------------------------- | | Here you may specify which middleware Jetstream will assign to the routes | that it registers with the application. When necessary, you may modify | these middleware; however, this default value is usually sufficient. | */ 'middleware' => ['web'], 'auth_session' => AuthenticateSession::class, /* |-------------------------------------------------------------------------- | Jetstream Guard |-------------------------------------------------------------------------- | | Here you may specify the authentication guard Jetstream will use while | authenticating users. This value should correspond with one of your | guards that is already present in your "auth" configuration file. | */ 'guard' => 'web', /* |-------------------------------------------------------------------------- | Features |-------------------------------------------------------------------------- | | Some of Jetstream's features are optional. You may disable the features | by removing them from this array. You're free to only remove some of | these features or you can even remove all of these if you need to. | */ 'features' => [ Features::termsAndPrivacyPolicy(), Features::profilePhotos(), Features::teams(['invitations' => true]), Features::accountDeletion(), ], /* |-------------------------------------------------------------------------- | Profile Photo Disk |-------------------------------------------------------------------------- | | This configuration value determines the default disk that will be used | when storing profile photos for your application's users. Typically | this will be the "public" disk but you may adjust this if needed. | */ 'profile_photo_disk' => env('PROFILE_PHOTO_DISK', env('PUBLIC_FILESYSTEM_DISK', 'public')), ]; ================================================ FILE: config/logging.php ================================================ env('LOG_CHANNEL', 'stack'), /* |-------------------------------------------------------------------------- | Deprecations Log Channel |-------------------------------------------------------------------------- | | This option controls the log channel that should be used to log warnings | regarding deprecated PHP and library features. This allows you to get | your application ready for upcoming major versions of dependencies. | */ 'deprecations' => [ 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), 'trace' => false, ], /* |-------------------------------------------------------------------------- | Log Channels |-------------------------------------------------------------------------- | | Here you may configure the log channels for your application. Out of | the box, Laravel uses the Monolog PHP logging library. This gives | you a variety of powerful log handlers / formatters to utilize. | | Available Drivers: "single", "daily", "slack", "syslog", | "errorlog", "monolog", | "custom", "stack" | */ 'channels' => [ 'stack' => [ 'driver' => 'stack', 'channels' => ['single'], 'ignore_exceptions' => false, ], 'single' => [ 'driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'stderr_daily' => [ 'driver' => 'stack', 'channels' => ['stderr', 'daily'], ], 'stack_production' => [ 'driver' => 'stack', 'channels' => ['stderr', 'sentry'], ], 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => 14, 'replace_placeholders' => true, ], 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'username' => 'Laravel Log', 'emoji' => ':boom:', 'level' => env('LOG_LEVEL', 'critical'), 'replace_placeholders' => true, ], 'sentry' => [ 'driver' => 'sentry', 'level' => env('LOG_LEVEL_SENTRY', 'error'), 'bubble' => true, ], 'papertrail' => [ 'driver' => 'monolog', 'level' => env('LOG_LEVEL', 'debug'), 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 'handler_with' => [ 'host' => env('PAPERTRAIL_URL'), 'port' => env('PAPERTRAIL_PORT'), 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), ], 'processors' => [PsrLogMessageProcessor::class], ], 'stderr' => [ 'driver' => 'monolog', 'level' => env('LOG_LEVEL', 'debug'), 'handler' => StreamHandler::class, 'formatter' => env('LOG_STDERR_FORMATTER'), 'with' => [ 'stream' => 'php://stderr', ], 'processors' => [PsrLogMessageProcessor::class], ], 'syslog' => [ 'driver' => 'syslog', 'level' => env('LOG_LEVEL', 'debug'), 'facility' => LOG_USER, 'replace_placeholders' => true, ], 'errorlog' => [ 'driver' => 'errorlog', 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'null' => [ 'driver' => 'monolog', 'handler' => NullHandler::class, ], 'emergency' => [ 'path' => storage_path('logs/laravel.log'), ], 'deprecation' => [ 'driver' => 'single', 'path' => storage_path('logs/deprecation.log'), ], ], ]; ================================================ FILE: config/mail.php ================================================ env('MAIL_MAILER', 'smtp'), /* |-------------------------------------------------------------------------- | Mailer Configurations |-------------------------------------------------------------------------- | | Here you may configure all of the mailers used by your application plus | their respective settings. Several examples have been configured for | you and you are free to add your own as your application requires. | | Laravel supports a variety of mail "transport" drivers to be used while | sending an e-mail. You will specify which one you are using for your | mailers below. You are free to add additional mailers as required. | | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", | "postmark", "log", "array", "failover", "roundrobin" | */ 'mailers' => [ 'smtp' => [ 'transport' => 'smtp', 'url' => env('MAIL_URL'), 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), 'port' => env('MAIL_PORT', 587), 'encryption' => env('MAIL_ENCRYPTION', 'tls'), 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), 'timeout' => null, 'local_domain' => env('MAIL_EHLO_DOMAIN'), ], 'ses' => [ 'transport' => 'ses', ], 'postmark' => [ 'transport' => 'postmark', // 'message_stream_id' => null, // 'client' => [ // 'timeout' => 5, // ], ], 'mailgun' => [ 'transport' => 'mailgun', // 'client' => [ // 'timeout' => 5, // ], ], 'sendmail' => [ 'transport' => 'sendmail', 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), ], 'log' => [ 'transport' => 'log', 'channel' => env('MAIL_LOG_CHANNEL'), ], 'array' => [ 'transport' => 'array', ], 'failover' => [ 'transport' => 'failover', 'mailers' => [ 'smtp', 'log', ], ], 'roundrobin' => [ 'transport' => 'roundrobin', 'mailers' => [ 'ses', 'postmark', ], ], ], /* |-------------------------------------------------------------------------- | Global "From" Address |-------------------------------------------------------------------------- | | You may wish for all e-mails sent by your application to be sent from | the same address. Here, you may specify a name and address that is | used globally for all e-mails that are sent by your application. | */ 'from' => [ 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 'name' => env('MAIL_FROM_NAME', 'Example'), ], 'reply_to' => [ 'address' => env('MAIL_REPLY_TO_ADDRESS'), 'name' => env('MAIL_REPLY_TO_NAME'), ], /* |-------------------------------------------------------------------------- | Markdown Mail Settings |-------------------------------------------------------------------------- | | If you are using Markdown based email rendering, you may configure your | theme and component paths here, allowing you to customize the design | of the emails. Or, you may simply stick with the Laravel defaults! | */ 'markdown' => [ 'theme' => 'default', 'paths' => [ resource_path('views/vendor/mail'), ], ], ]; ================================================ FILE: config/modules.php ================================================ 'Extensions', /* |-------------------------------------------------------------------------- | Module Stubs |-------------------------------------------------------------------------- | | Default module stubs. | */ 'stubs' => [ 'enabled' => false, 'path' => base_path('vendor/nwidart/laravel-modules/src/Commands/stubs'), 'files' => [ 'routes/web' => 'routes/web.php', 'routes/api' => 'routes/api.php', 'views/index' => 'resources/views/index.blade.php', 'views/master' => 'resources/views/layouts/master.blade.php', 'scaffold/config' => 'config/config.php', 'composer' => 'composer.json', 'assets/js/app' => 'resources/assets/js/app.js', 'assets/sass/app' => 'resources/assets/sass/app.scss', 'vite' => 'vite.config.js', 'package' => 'package.json', ], 'replacements' => [ 'routes/web' => ['LOWER_NAME', 'STUDLY_NAME', 'MODULE_NAMESPACE', 'CONTROLLER_NAMESPACE'], 'routes/api' => ['LOWER_NAME', 'STUDLY_NAME', 'MODULE_NAMESPACE', 'CONTROLLER_NAMESPACE'], 'vite' => ['LOWER_NAME', 'STUDLY_NAME'], 'json' => ['LOWER_NAME', 'STUDLY_NAME', 'MODULE_NAMESPACE', 'PROVIDER_NAMESPACE'], 'views/index' => ['LOWER_NAME'], 'views/master' => ['LOWER_NAME', 'STUDLY_NAME'], 'scaffold/config' => ['STUDLY_NAME'], 'composer' => [ 'LOWER_NAME', 'STUDLY_NAME', 'VENDOR', 'AUTHOR_NAME', 'AUTHOR_EMAIL', 'MODULE_NAMESPACE', 'PROVIDER_NAMESPACE', 'APP_FOLDER_NAME', ], ], 'gitkeep' => true, ], 'paths' => [ /* |-------------------------------------------------------------------------- | Modules path |-------------------------------------------------------------------------- | | This path is used to save the generated module. | This path will also be added automatically to the list of scanned folders. | */ 'modules' => base_path('extensions'), /* |-------------------------------------------------------------------------- | Modules assets path |-------------------------------------------------------------------------- | | Here you may update the modules' assets path. | */ 'assets' => public_path('extensions'), /* |-------------------------------------------------------------------------- | The migrations' path |-------------------------------------------------------------------------- | | Where you run the 'module:publish-migration' command, where do you publish the | the migration files? | */ 'migration' => base_path('database/migrations'), /* |-------------------------------------------------------------------------- | The app path |-------------------------------------------------------------------------- | | app folder name | for example can change it to 'src' or 'App' */ 'app_folder' => 'app/', /* |-------------------------------------------------------------------------- | Generator path |-------------------------------------------------------------------------- | Customise the paths where the folders will be generated. | Setting the generate key to false will not generate that folder */ 'generator' => [ // app/ 'channels' => ['path' => 'app/Broadcasting', 'generate' => false], 'command' => ['path' => 'app/Console', 'generate' => false], 'emails' => ['path' => 'app/Emails', 'generate' => false], 'event' => ['path' => 'app/Events', 'generate' => false], 'jobs' => ['path' => 'app/Jobs', 'generate' => false], 'listener' => ['path' => 'app/Listeners', 'generate' => false], 'model' => ['path' => 'app/Models', 'generate' => false], 'notifications' => ['path' => 'app/Notifications', 'generate' => false], 'observer' => ['path' => 'app/Observers', 'generate' => false], 'policies' => ['path' => 'app/Policies', 'generate' => false], 'provider' => ['path' => 'app/Providers', 'generate' => true], 'route-provider' => ['path' => 'app/Providers', 'generate' => true], 'repository' => ['path' => 'app/Repositories', 'generate' => false], 'resource' => ['path' => 'app/Transformers', 'generate' => false], 'rules' => ['path' => 'app/Rules', 'generate' => false], 'component-class' => ['path' => 'app/View/Components', 'generate' => false], 'service' => ['path' => 'app/Services', 'generate' => false], // app/Http/ 'controller' => ['path' => 'app/Http/Controllers', 'generate' => true], 'filter' => ['path' => 'app/Http/Middleware', 'generate' => false], 'request' => ['path' => 'app/Http/Requests', 'generate' => false], // config/ 'config' => ['path' => 'config', 'generate' => true], // database/ 'migration' => ['path' => 'database/migrations', 'generate' => true], 'seeder' => ['path' => 'database/seeders', 'namespace' => 'Database\Seeders', 'generate' => true], 'factory' => ['path' => 'database/factories', 'namespace' => 'Database\Factories', 'generate' => true], // lang/ 'lang' => ['path' => 'lang', 'generate' => false], // resource/ 'assets' => ['path' => 'resources/assets', 'generate' => true], 'views' => ['path' => 'resources/views', 'generate' => true], 'component-view' => ['path' => 'resources/views/components', 'generate' => false], // routes/ 'routes' => ['path' => 'routes', 'generate' => true], // tests/ 'test-unit' => ['path' => 'tests/Unit', 'generate' => true], 'test-feature' => ['path' => 'tests/Feature', 'generate' => true], ], ], /* |-------------------------------------------------------------------------- | Package commands |-------------------------------------------------------------------------- | | Here you can define which commands will be visible and used in your | application. You can add your own commands to merge section. | */ 'commands' => ConsoleServiceProvider::defaultCommands() ->merge([ // New commands go here ])->toArray(), /* |-------------------------------------------------------------------------- | Scan Path |-------------------------------------------------------------------------- | | Here you define which folder will be scanned. By default will scan vendor | directory. This is useful if you host the package in packagist website. | */ 'scan' => [ 'enabled' => false, 'paths' => [ base_path('vendor/*/*'), ], ], /* |-------------------------------------------------------------------------- | Composer File Template |-------------------------------------------------------------------------- | | Here is the config for the composer.json file, generated by this package | */ 'composer' => [ 'vendor' => env('MODULE_VENDOR', 'solidtime-io'), 'author' => [ 'name' => env('MODULE_AUTHOR_NAME', 'Nicolas Widart'), 'email' => env('MODULE_AUTHOR_EMAIL', 'n.widart@gmail.com'), ], 'composer-output' => false, ], /* |-------------------------------------------------------------------------- | Caching |-------------------------------------------------------------------------- | | Here is the config for setting up the caching feature. | */ 'cache' => [ 'enabled' => env('MODULES_CACHE_ENABLED', false), 'driver' => env('MODULES_CACHE_DRIVER', 'file'), 'key' => env('MODULES_CACHE_KEY', 'laravel-modules'), 'lifetime' => env('MODULES_CACHE_LIFETIME', 60), ], /* |-------------------------------------------------------------------------- | Choose what laravel-modules will register as custom namespaces. | Setting one to false will require you to register that part | in your own Service Provider class. |-------------------------------------------------------------------------- */ 'register' => [ 'translations' => true, /** * load files on boot or register method */ 'files' => 'register', ], /* |-------------------------------------------------------------------------- | Activators |-------------------------------------------------------------------------- | | You can define new types of activators here, file, database, etc. The only | required parameter is 'class'. | The file activator will store the activation status in storage/installed_modules */ 'activators' => [ 'file' => [ 'class' => FileActivator::class, 'statuses-file' => base_path('modules_statuses.json'), 'cache-key' => 'activator.installed', 'cache-lifetime' => 604800, ], ], 'activator' => 'file', ]; ================================================ FILE: config/octane.php ================================================ env('OCTANE_SERVER', 'frankenphp'), /* |-------------------------------------------------------------------------- | Force HTTPS |-------------------------------------------------------------------------- | | When this configuration value is set to "true", Octane will inform the | framework that all absolute links must be generated using the HTTPS | protocol. Otherwise your links may be generated using plain HTTP. | */ 'https' => env('OCTANE_HTTPS', false), /* |-------------------------------------------------------------------------- | Octane Listeners |-------------------------------------------------------------------------- | | All of the event listeners for Octane's events are defined below. These | listeners are responsible for resetting your application's state for | the next request. You may even add your own listeners to the list. | */ 'listeners' => [ WorkerStarting::class => [ EnsureUploadedFilesAreValid::class, EnsureUploadedFilesCanBeMoved::class, ], RequestReceived::class => [ ...Octane::prepareApplicationForNextOperation(), ...Octane::prepareApplicationForNextRequest(), // ], RequestHandled::class => [ // ], RequestTerminated::class => [ // FlushUploadedFiles::class, ], TaskReceived::class => [ ...Octane::prepareApplicationForNextOperation(), // ], TaskTerminated::class => [ // ], TickReceived::class => [ ...Octane::prepareApplicationForNextOperation(), // ], TickTerminated::class => [ // ], OperationTerminated::class => [ FlushOnce::class, FlushTemporaryContainerInstances::class, // DisconnectFromDatabases::class, // CollectGarbage::class, ], WorkerErrorOccurred::class => [ ReportException::class, StopWorkerIfNecessary::class, ], WorkerStopping::class => [ CloseMonologHandlers::class, ], ], /* |-------------------------------------------------------------------------- | Warm / Flush Bindings |-------------------------------------------------------------------------- | | The bindings listed below will either be pre-warmed when a worker boots | or they will be flushed before every new request. Flushing a binding | will force the container to resolve that binding again when asked. | */ 'warm' => [ ...Octane::defaultServicesToWarm(), ], 'flush' => [ // ], /* |-------------------------------------------------------------------------- | Octane Swoole Tables |-------------------------------------------------------------------------- | | While using Swoole, you may define additional tables as required by the | application. These tables can be used to store data that needs to be | quickly accessed by other workers on the particular Swoole server. | */ 'tables' => [ 'example:1000' => [ 'name' => 'string:1000', 'votes' => 'int', ], ], /* |-------------------------------------------------------------------------- | Octane Swoole Cache Table |-------------------------------------------------------------------------- | | While using Swoole, you may leverage the Octane cache, which is powered | by a Swoole table. You may set the maximum number of rows as well as | the number of bytes per row using the configuration options below. | */ 'cache' => [ 'rows' => 1000, 'bytes' => 10000, ], /* |-------------------------------------------------------------------------- | File Watching |-------------------------------------------------------------------------- | | The following list of files and directories will be watched when using | the --watch option offered by Octane. If any of the directories and | files are changed, Octane will automatically reload your workers. | */ 'watch' => [ 'app', 'bootstrap', 'config/**/*.php', 'database/**/*.php', 'public/**/*.php', 'resources/**/*.php', 'routes', 'composer.lock', '.env', ], /* |-------------------------------------------------------------------------- | Garbage Collection Threshold |-------------------------------------------------------------------------- | | When executing long-lived PHP scripts such as Octane, memory can build | up before being cleared by PHP. You can force Octane to run garbage | collection if your application consumes this amount of megabytes. | */ 'garbage' => 50, /* |-------------------------------------------------------------------------- | Maximum Execution Time |-------------------------------------------------------------------------- | | The following setting configures the maximum execution time for requests | being handled by Octane. You may set this value to 0 to indicate that | there isn't a specific time limit on Octane request execution time. | */ 'max_execution_time' => 120, /** * Custom swoole config * * @source https://github.com/exaco/laravel-octane-dockerfile?tab=readme-ov-file#recommended-swoole-options-in-octanephp */ 'swoole' => [ 'options' => [ 'http_compression' => true, 'http_compression_level' => 6, // 1 - 9 'compression_min_length' => 20, 'package_max_length' => 20 * 1024 * 1024, // 20MB 'open_http2_protocol' => true, 'document_root' => public_path(), 'enable_static_handler' => true, ], ], ]; ================================================ FILE: config/passport.php ================================================ 'web', /* |-------------------------------------------------------------------------- | Encryption Keys |-------------------------------------------------------------------------- | | Passport uses encryption keys while generating secure access tokens for | your application. By default, the keys are stored as local files but | can be set via environment variables when that is more convenient. | */ 'private_key' => env('PASSPORT_PRIVATE_KEY'), 'public_key' => env('PASSPORT_PUBLIC_KEY'), /* |-------------------------------------------------------------------------- | Passport Database Connection |-------------------------------------------------------------------------- | | By default, Passport's models will utilize your application's default | database connection. If you wish to use a different connection you | may specify the configured name of the database connection here. | */ 'connection' => env('PASSPORT_CONNECTION'), ]; ================================================ FILE: config/queue.php ================================================ env('QUEUE_CONNECTION', 'sync'), /* |-------------------------------------------------------------------------- | Queue Connections |-------------------------------------------------------------------------- | | Here you may configure the connection information for each server that | is used by your application. A default configuration has been added | for each back-end shipped with Laravel. You are free to add more. | | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" | */ 'connections' => [ 'sync' => [ 'driver' => 'sync', ], 'database' => [ 'driver' => 'database', 'table' => 'jobs', 'queue' => 'default', 'retry_after' => 90, 'after_commit' => false, ], 'beanstalkd' => [ 'driver' => 'beanstalkd', 'host' => 'localhost', 'queue' => 'default', 'retry_after' => 90, 'block_for' => 0, 'after_commit' => false, ], 'sqs' => [ 'driver' => 'sqs', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), 'queue' => env('SQS_QUEUE', 'default'), 'suffix' => env('SQS_SUFFIX'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'after_commit' => false, ], 'redis' => [ 'driver' => 'redis', 'connection' => 'default', 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => 90, 'block_for' => null, 'after_commit' => false, ], ], /* |-------------------------------------------------------------------------- | Job Batching |-------------------------------------------------------------------------- | | The following options configure the database and table that store job | batching information. These options can be updated to any database | connection and table which has been defined by your application. | */ 'batching' => [ 'database' => env('DB_CONNECTION', 'mysql'), 'table' => 'job_batches', ], /* |-------------------------------------------------------------------------- | Failed Queue Jobs |-------------------------------------------------------------------------- | | These options configure the behavior of failed queue job logging so you | can control which database and table are used to store the jobs that | have failed. You may change them to any database / table you wish. | */ 'failed' => [ 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'database' => env('DB_CONNECTION', 'mysql'), 'table' => 'failed_jobs', ], ]; ================================================ FILE: config/scheduling.php ================================================ [ 'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true), 'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true), 'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true), 'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true), 'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false), ], ]; ================================================ FILE: config/scramble.php ================================================ 'api', /* * Your API domain. By default, app domain is used. This is also a part of the default API routes * matcher, so when implementing your own, make sure you use this config if needed. */ 'api_domain' => null, 'info' => [ /* * API version. */ 'version' => '0.0.1', /* * Description rendered on the home page of the API documentation (`/docs/api`). */ 'description' => '', ], /* * Customize Stoplight Elements UI */ 'ui' => [ /* * Hide the `Try It` feature. Enabled by default. */ 'hide_try_it' => false, /* * URL to an image that displays as a small square logo next to the title, above the table of contents. */ 'logo' => '', /* * Use to fetch the credential policy for the Try It feature. Options are: omit, include (default), and same-origin */ 'try_it_credentials_policy' => 'include', ], /* * The list of servers of the API. By default, when `null`, server URL will be created from * `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you * will need to specify the local server URL manually (if needed). * * Example of non-default config (final URLs are generated using Laravel `url` helper): * * ```php * 'servers' => [ * 'Live' => 'api', * 'Prod' => 'https://scramble.dedoc.co/api', * ], * ``` */ 'servers' => [ 'Production' => 'https://app.solidtime.io/api', 'Staging' => 'https://app.staging.solidtime.io/api', 'Local' => 'https://solidtime.test/api', ], 'middleware' => [ 'web', RestrictedDocsAccess::class, ], 'extensions' => [ ApiExceptionTypeToSchema::class, PaginatedResourceCollectionTypeToSchema::class, ], ]; ================================================ FILE: config/services.php ================================================ [ 'url' => env('GOTENBERG_URL'), 'basic_auth_username' => env('GOTENBERG_BASIC_AUTH_USERNAME'), 'basic_auth_password' => env('GOTENBERG_BASIC_AUTH_PASSWORD'), ], ]; ================================================ FILE: config/session.php ================================================ env('SESSION_DRIVER', 'database'), /* |-------------------------------------------------------------------------- | Session Lifetime |-------------------------------------------------------------------------- | | Here you may specify the number of minutes that you wish the session | to be allowed to remain idle before it expires. If you want them | to immediately expire on the browser closing, set that option. | */ 'lifetime' => env('SESSION_LIFETIME', 120), 'expire_on_close' => false, /* |-------------------------------------------------------------------------- | Session Encryption |-------------------------------------------------------------------------- | | This option allows you to easily specify that all of your session data | should be encrypted before it is stored. All encryption will be run | automatically by Laravel and you can use the Session like normal. | */ 'encrypt' => false, /* |-------------------------------------------------------------------------- | Session File Location |-------------------------------------------------------------------------- | | When using the native session driver, we need a location where session | files may be stored. A default has been set for you but a different | location may be specified. This is only needed for file sessions. | */ 'files' => storage_path('framework/sessions'), /* |-------------------------------------------------------------------------- | Session Database Connection |-------------------------------------------------------------------------- | | When using the "database" or "redis" session drivers, you may specify a | connection that should be used to manage these sessions. This should | correspond to a connection in your database configuration options. | */ 'connection' => env('SESSION_CONNECTION'), /* |-------------------------------------------------------------------------- | Session Database Table |-------------------------------------------------------------------------- | | When using the "database" session driver, you may specify the table we | should use to manage the sessions. Of course, a sensible default is | provided for you; however, you are free to change this as needed. | */ 'table' => 'sessions', /* |-------------------------------------------------------------------------- | Session Cache Store |-------------------------------------------------------------------------- | | While using one of the framework's cache driven session backends you may | list a cache store that should be used for these sessions. This value | must match with one of the application's configured cache "stores". | | Affects: "apc", "dynamodb", "memcached", "redis" | */ 'store' => env('SESSION_STORE'), /* |-------------------------------------------------------------------------- | Session Sweeping Lottery |-------------------------------------------------------------------------- | | Some session drivers must manually sweep their storage location to get | rid of old sessions from storage. Here are the chances that it will | happen on a given request. By default, the odds are 2 out of 100. | */ 'lottery' => [2, 100], /* |-------------------------------------------------------------------------- | Session Cookie Name |-------------------------------------------------------------------------- | | Here you may change the name of the cookie used to identify a session | instance by ID. The name specified here will get used every time a | new session cookie is created by the framework for every driver. | */ 'cookie' => env( 'SESSION_COOKIE', 'solidtime_session' ), /* |-------------------------------------------------------------------------- | Session Cookie Path |-------------------------------------------------------------------------- | | The session cookie path determines the path for which the cookie will | be regarded as available. Typically, this will be the root path of | your application but you are free to change this when necessary. | */ 'path' => '/', /* |-------------------------------------------------------------------------- | Session Cookie Domain |-------------------------------------------------------------------------- | | Here you may change the domain of the cookie used to identify a session | in your application. This will determine which domains the cookie is | available to in your application. A sensible default has been set. | */ 'domain' => env('SESSION_DOMAIN'), /* |-------------------------------------------------------------------------- | HTTPS Only Cookies |-------------------------------------------------------------------------- | | By setting this option to true, session cookies will only be sent back | to the server if the browser has a HTTPS connection. This will keep | the cookie from being sent to you when it can't be done securely. | */ 'secure' => env('SESSION_SECURE_COOKIE', env('APP_FORCE_HTTPS')), /* |-------------------------------------------------------------------------- | HTTP Access Only |-------------------------------------------------------------------------- | | Setting this value to true will prevent JavaScript from accessing the | value of the cookie and the cookie will only be accessible through | the HTTP protocol. You are free to modify this option if needed. | */ 'http_only' => true, /* |-------------------------------------------------------------------------- | Same-Site Cookies |-------------------------------------------------------------------------- | | This option determines how your cookies behave when cross-site requests | take place, and can be used to mitigate CSRF attacks. By default, we | will set this value to "lax" since this is a secure default value. | | Supported: "lax", "strict", "none", null | */ 'same_site' => 'lax', /* |-------------------------------------------------------------------------- | Partitioned Cookies |-------------------------------------------------------------------------- | | Setting this value to true will tie the cookie to the top-level site for | a cross-site context. Partitioned cookies are accepted by the browser | when flagged "secure" and the Same-Site attribute is set to "none". | */ 'partitioned' => false, ]; ================================================ FILE: config/telescope.php ================================================ env('TELESCOPE_DOMAIN'), /* |-------------------------------------------------------------------------- | Telescope Path |-------------------------------------------------------------------------- | | This is the URI path where Telescope will be accessible from. Feel free | to change this path to anything you like. Note that the URI will not | affect the paths of its internal API that aren't exposed to users. | */ 'path' => env('TELESCOPE_PATH', 'telescope'), /* |-------------------------------------------------------------------------- | Telescope Storage Driver |-------------------------------------------------------------------------- | | This configuration options determines the storage driver that will | be used to store Telescope's data. In addition, you may set any | custom options as needed by the particular driver you choose. | */ 'driver' => env('TELESCOPE_DRIVER', 'database'), 'storage' => [ 'database' => [ 'connection' => env('DB_CONNECTION', 'mysql'), 'chunk' => 1000, ], ], /* |-------------------------------------------------------------------------- | Telescope Master Switch |-------------------------------------------------------------------------- | | This option may be used to disable all Telescope watchers regardless | of their individual configuration, which simply provides a single | and convenient way to enable or disable Telescope data storage. | */ 'enabled' => env('TELESCOPE_ENABLED', true), /* |-------------------------------------------------------------------------- | Telescope Route Middleware |-------------------------------------------------------------------------- | | These middleware will be assigned to every Telescope route, giving you | the chance to add your own middleware to this list or change any of | the existing middleware. Or, you can simply stick with this list. | */ 'middleware' => [ 'web', Authorize::class, ], /* |-------------------------------------------------------------------------- | Allowed / Ignored Paths & Commands |-------------------------------------------------------------------------- | | The following array lists the URI paths and Artisan commands that will | not be watched by Telescope. In addition to this list, some Laravel | commands, like migrations and queue commands, are always ignored. | */ 'only_paths' => [ // 'api/*' ], 'ignore_paths' => [ 'nova-api*', 'pulse*', ], 'ignore_commands' => [ // ], /* |-------------------------------------------------------------------------- | Telescope Watchers |-------------------------------------------------------------------------- | | The following array lists the "watchers" that will be registered with | Telescope. The watchers gather the application's profile data when | a request or task is executed. Feel free to customize this list. | */ 'watchers' => [ Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true), Watchers\CacheWatcher::class => [ 'enabled' => env('TELESCOPE_CACHE_WATCHER', true), 'hidden' => [], ], Watchers\ClientRequestWatcher::class => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true), Watchers\CommandWatcher::class => [ 'enabled' => env('TELESCOPE_COMMAND_WATCHER', true), 'ignore' => [], ], Watchers\DumpWatcher::class => [ 'enabled' => env('TELESCOPE_DUMP_WATCHER', true), 'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false), ], Watchers\EventWatcher::class => [ 'enabled' => env('TELESCOPE_EVENT_WATCHER', true), 'ignore' => [], ], Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true), Watchers\GateWatcher::class => [ 'enabled' => env('TELESCOPE_GATE_WATCHER', true), 'ignore_abilities' => [], 'ignore_packages' => true, 'ignore_paths' => [], ], Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true), Watchers\LogWatcher::class => [ 'enabled' => env('TELESCOPE_LOG_WATCHER', true), 'level' => 'debug', ], Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true), Watchers\ModelWatcher::class => [ 'enabled' => env('TELESCOPE_MODEL_WATCHER', true), 'events' => ['eloquent.*'], 'hydrations' => true, ], Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true), Watchers\QueryWatcher::class => [ 'enabled' => env('TELESCOPE_QUERY_WATCHER', true), 'ignore_packages' => true, 'ignore_paths' => [], 'slow' => 100, ], Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true), Watchers\RequestWatcher::class => [ 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true), 'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64), 'ignore_http_methods' => [], 'ignore_status_codes' => [], ], Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true), Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true), ], ]; ================================================ FILE: config/trustedproxy.php ================================================ ! is_string(env('TRUSTED_PROXIES', null)) ? [] : explode(',', env('TRUSTED_PROXIES')), ]; ================================================ FILE: config/view.php ================================================ [ resource_path('views'), ], /* |-------------------------------------------------------------------------- | Compiled View Path |-------------------------------------------------------------------------- | | This option determines where all the compiled Blade templates will be | stored for your application. Typically, this is within the storage | directory. However, as usual, you are free to change this value. | */ 'compiled' => env( 'VIEW_COMPILED_PATH', realpath(storage_path('framework/views')) ), ]; ================================================ FILE: database/.gitignore ================================================ *.sqlite* ================================================ FILE: database/factories/AuditFactory.php ================================================ */ class AuditFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { $morphPrefix = Config::get('audit.user.morph_prefix', 'user'); return [ $morphPrefix.'_id' => function () { return User::factory()->create()->id; }, $morphPrefix.'_type' => function () { return (new User)->getMorphClass(); }, 'event' => 'updated', 'auditable_id' => function () { return User::factory()->create()->getKey(); }, 'auditable_type' => function () { return (new User)->getMorphClass(); }, 'old_values' => [], 'new_values' => [], 'url' => $this->faker->url, 'ip_address' => $this->faker->ipv4, 'user_agent' => $this->faker->userAgent, 'tags' => implode(',', $this->faker->words(4)), ]; } public function auditUser(User $user): self { return $this->state(function (array $attributes) use ($user) { $morphPrefix = Config::get('audit.user.morph_prefix', 'user'); return [ $morphPrefix.'_id' => $user->getKey(), $morphPrefix.'_type' => $user->getMorphClass(), ]; }); } public function auditFor(Model $model): self { return $this->state(function (array $attributes) use ($model) { return [ 'auditable_id' => $model->getKey(), 'auditable_type' => $model->getMorphClass(), ]; }); } } ================================================ FILE: database/factories/ClientFactory.php ================================================ */ class ClientFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'name' => $this->faker->company(), 'archived_at' => null, 'organization_id' => Organization::factory(), ]; } public function forOrganization(Organization $organization): self { return $this->state(fn (array $attributes) => [ 'organization_id' => $organization->getKey(), ]); } public function randomCreatedAt(): self { return $this->state(function (array $attributes): array { return [ 'created_at' => $this->faker->dateTimeBetween('-1 day', 'now'), ]; }); } public function archived(): self { return $this->state(function (array $attributes): array { return [ 'archived_at' => $this->faker->dateTime(), ]; }); } } ================================================ FILE: database/factories/MemberFactory.php ================================================ */ class MemberFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'billable_rate' => null, 'role' => Role::Employee, 'organization_id' => Organization::factory(), 'user_id' => User::factory(), ]; } public function role(Role $role): static { return $this->state(function (array $attributes) use ($role): array { return [ 'role' => $role->value, ]; }); } public function forOrganization(Organization $organization): static { return $this->state(fn (array $attributes): array => [ 'organization_id' => $organization->getKey(), ]); } public function forUser(User $user): static { return $this->state(fn (array $attributes): array => [ 'user_id' => $user->getKey(), ]); } /** * Indicate that the model's email address should be unverified. */ public function unverified(): static { return $this->state(function (array $attributes) { return [ 'email_verified_at' => null, ]; }); } public function billableRate(?int $billableRate): self { return $this->state(fn (array $attributes) => [ 'billable_rate' => $billableRate, ]); } public function withBillableRate(): self { return $this->state(fn (array $attributes) => [ 'billable_rate' => $this->faker->numberBetween(50, 1000) * 100, ]); } public function attachToOrganization(Organization $organization, array $pivot = []): static { return $this->afterCreating(function (User $user) use ($organization, $pivot): void { $user->organizations()->attach($organization, $pivot); }); } } ================================================ FILE: database/factories/OrganizationFactory.php ================================================ */ class OrganizationFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'name' => $this->faker->unique()->company(), 'currency' => app(CurrencyService::class)->getRandomCurrencyCode(), 'billable_rate' => null, 'user_id' => User::factory(), 'personal_team' => true, 'employees_can_see_billable_rates' => false, 'number_format' => $this->faker->randomElement(NumberFormat::values()), 'currency_format' => $this->faker->randomElement(CurrencyFormat::values()), 'date_format' => $this->faker->randomElement(DateFormat::values()), 'interval_format' => $this->faker->randomElement(IntervalFormat::values()), 'time_format' => $this->faker->randomElement(TimeFormat::values()), ]; } public function billableRate(?int $billableRate): self { return $this->state(fn (array $attributes) => [ 'billable_rate' => $billableRate, ]); } public function withBillableRate(): self { return $this->state(fn (array $attributes) => [ 'billable_rate' => $this->faker->numberBetween(50, 1000) * 100, ]); } public function withOwner(?User $owner = null): self { return $this->state(fn (array $attributes) => [ 'user_id' => $owner === null ? User::factory() : $owner->getKey(), ]); } public function withFakeId(): self { return $this->state(fn (array $attributes) => [ 'id' => $this->faker->uuid(), ]); } } ================================================ FILE: database/factories/OrganizationInvitationFactory.php ================================================ */ class OrganizationInvitationFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'email' => $this->faker->unique()->safeEmail(), 'role' => Role::Employee->value, 'organization_id' => Organization::factory(), ]; } public function forOrganization(Organization $organization): self { return $this->state(fn (array $attributes) => [ 'organization_id' => $organization->getKey(), ]); } } ================================================ FILE: database/factories/Passport/ClientFactory.php ================================================ */ class ClientFactory extends BaseClientFactory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'id' => $this->faker->uuid, 'owner_id' => null, 'owner_type' => null, 'name' => $this->faker->company(), 'secret' => $this->faker->regexify('[A-Za-z]{40}'), 'provider' => 'users', 'redirect_uris' => [$this->faker->url()], 'grant_types' => [], 'revoked' => false, 'created_at' => $this->faker->dateTime(), 'updated_at' => $this->faker->dateTime(), ]; } public function desktopClient(): self { return $this->state(fn (array $attributes) => [ 'name' => 'Desktop', 'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'authorization_code', 'implicit'], ]); } public function apiClient(): self { return $this->state(fn (array $attributes) => [ 'name' => 'API', 'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'client_credentials', 'personal_access'], ]); } public function personalAccessClient(): self { return $this->state(function (array $attributes) { return [ 'grant_types' => ['personal_access'], ]; }); } public function forUser(User $user): self { return $this->state(function (array $attributes) use ($user): array { return [ 'owner_id' => $user->getKey(), 'owner_type' => (new User)->getMorphClass(), ]; }); } } ================================================ FILE: database/factories/Passport/TokenFactory.php ================================================ */ class TokenFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'id' => $this->faker->uuid, 'user_id' => null, 'client_id' => $this->faker->uuid, 'name' => null, 'scopes' => [], 'revoked' => false, 'created_at' => $this->faker->dateTime, 'updated_at' => $this->faker->dateTime, 'expires_at' => $this->faker->dateTime, 'reminder_sent_at' => null, 'expired_info_sent_at' => null, ]; } public function forUser(User $user): self { return $this->state(function (array $attributes) use ($user): array { return [ 'user_id' => $user->getKey(), ]; }); } public function forClient(Client $client): self { return $this->state(function (array $attributes) use ($client): array { return [ 'client_id' => $client->getKey(), ]; }); } } ================================================ FILE: database/factories/ProjectFactory.php ================================================ */ class ProjectFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'name' => $this->faker->company(), 'color' => app(ColorService::class)->getRandomColor(), 'is_billable' => false, 'billable_rate' => null, 'is_public' => false, 'archived_at' => null, 'client_id' => null, 'organization_id' => Organization::factory(), 'estimated_time' => null, ]; } public function withEstimatedTime(): self { return $this->state(function (array $attributes): array { return [ 'estimated_time' => $this->faker->randomNumber(3), ]; }); } public function billable(?int $billableRate = null): self { return $this->state(function (array $attributes) use ($billableRate): array { return [ 'is_billable' => true, 'billable_rate' => $billableRate === null ? $this->faker->numberBetween(50, 1000) * 100 : $billableRate, ]; }); } public function createdAt(Carbon $createdAt): self { return $this->state(function (array $attributes) use ($createdAt): array { return [ 'created_at' => $createdAt, ]; }); } public function archived(): self { return $this->state(function (array $attributes): array { return [ 'archived_at' => $this->faker->dateTime(), ]; }); } public function forOrganization(Organization $organization): self { return $this->state(function (array $attributes) use ($organization): array { return [ 'organization_id' => $organization->getKey(), ]; }); } public function isPublic(): self { return $this->state(function (array $attributes): array { return [ 'is_public' => true, ]; }); } public function isPrivate(): self { return $this->state(function (array $attributes): array { return [ 'is_public' => false, ]; }); } public function addMember(Member $member, array $attributes = []): self { return $this->afterCreating(function (Project $project) use ($member, $attributes): void { ProjectMember::factory() ->forProject($project) ->forMember($member) ->create($attributes); }); } public function withClient(): self { return $this->state(function (array $attributes): array { return [ 'client_id' => Client::factory(), ]; }); } public function forClient(?Client $client): self { return $this->state(function (array $attributes) use ($client): array { return [ 'client_id' => $client?->getKey(), ]; }); } } ================================================ FILE: database/factories/ProjectMemberFactory.php ================================================ */ class ProjectMemberFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'billable_rate' => $this->faker->numberBetween(10, 10000) * 100, 'project_id' => Project::factory(), 'user_id' => User::factory(), 'member_id' => Member::factory(), ]; } /** * @deprecated Use forMember instead */ public function forUser(User $user): self { return $this->state(function (array $attributes) use ($user): array { return [ 'user_id' => $user->getKey(), ]; }); } public function forMember(Member $member): self { return $this->state(function (array $attributes) use ($member): array { return [ 'member_id' => $member->getKey(), 'user_id' => $member->user_id, // Legacy ]; }); } public function forProject(Project $project): self { return $this->state(function (array $attributes) use ($project): array { return [ 'project_id' => $project->getKey(), ]; }); } } ================================================ FILE: database/factories/ReportFactory.php ================================================ */ class ReportFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { $reportDto = new ReportPropertiesDto; $reportDto->start = Carbon::createFromDate($this->faker->dateTimeBetween('-1 year', '-1 month')); $reportDto->end = Carbon::createFromDate($this->faker->dateTimeBetween('-1 month', 'now')); $reportDto->group = TimeEntryAggregationType::Project; $reportDto->subGroup = TimeEntryAggregationType::Task; $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; $reportDto->weekStart = Weekday::from($this->faker->randomElement(Weekday::values())); $reportDto->timezone = $this->faker->timezone(); return [ 'name' => $this->faker->company(), 'description' => $this->faker->paragraph(), 'is_public' => $this->faker->boolean(), 'properties' => $reportDto, 'organization_id' => Organization::factory(), ]; } public function randomCreatedAt(): self { return $this->state(fn (array $attributes): array => [ 'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'), ]); } public function public(): self { return $this->state(fn (array $attributes): array => [ 'is_public' => true, 'share_secret' => app(ReportService::class)->generateSecret(), ]); } public function private(): self { return $this->state(fn (array $attributes): array => [ 'is_public' => false, 'share_secret' => null, ]); } public function forOrganization(Organization $organization): self { return $this->state(fn (array $attributes): array => [ 'organization_id' => $organization->getKey(), ]); } } ================================================ FILE: database/factories/TagFactory.php ================================================ */ class TagFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'name' => $this->faker->name(), 'organization_id' => Organization::factory(), ]; } public function forOrganization(Organization $organization): self { return $this->state(function (array $attributes) use ($organization) { return [ 'organization_id' => $organization->getKey(), ]; }); } public function randomCreatedAt(): self { return $this->state(function (array $attributes): array { return [ 'created_at' => $this->faker->dateTimeBetween('-1 day', 'now'), ]; }); } } ================================================ FILE: database/factories/TaskFactory.php ================================================ */ class TaskFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'name' => $this->faker->word(), 'project_id' => Project::factory(), 'organization_id' => Organization::factory(), 'done_at' => null, 'estimated_time' => null, ]; } public function forProject(Project $project): self { return $this->state(fn (array $attributes) => [ 'project_id' => $project->getKey(), ]); } public function isDone(): self { return $this->state(fn (array $attributes) => [ 'done_at' => $this->faker->dateTime('now', 'UTC'), ]); } public function forOrganization(Organization $organization): self { return $this->state(fn (array $attributes) => [ 'organization_id' => $organization->getKey(), ]); } } ================================================ FILE: database/factories/TimeEntryFactory.php ================================================ */ class TimeEntryFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { $start = $this->faker->dateTimeBetween('-1 year', '-1 hour'); return [ 'description' => $this->faker->sentence(), 'start' => $start, 'end' => $this->faker->dateTimeBetween($start, 'now'), 'billable' => $this->faker->boolean(), 'is_imported' => false, 'tags' => [], 'user_id' => User::factory(), 'member_id' => Member::factory(), 'task_id' => null, 'project_id' => null, 'organization_id' => Organization::factory(), 'billable_rate' => null, ]; } public function notBillable(): self { return $this->state(function (array $attributes): array { return [ 'billable' => false, ]; }); } public function billableRate(int $billableRate): self { return $this->state(function (array $attributes) use ($billableRate): array { return [ 'billable' => true, 'billable_rate' => $billableRate, ]; }); } public function withTask(Organization $organization): self { return $this->state(function (array $attributes) use (&$organization): array { $project = Project::factory()->forOrganization($organization)->create(); $task = Task::factory()->forProject($project)->forOrganization($organization)->create(); return [ 'task_id' => $task->getKey(), 'project_id' => $task->project->getKey(), ]; }); } public function withTags(Organization $organization): self { return $this->state(function (array $attributes) use ($organization): array { return [ 'tags' => [ Tag::factory()->forOrganization($organization)->create()->getKey(), Tag::factory()->forOrganization($organization)->create()->getKey(), ], ]; }); } public function startBetween(Carbon $rangeStart, Carbon $rangeEnd, bool $fixedValueForMultiple = false): self { $fixedStart = Carbon::instance($this->faker->dateTimeBetween($rangeStart, $rangeEnd)); return $this->state(function (array $attributes) use ($rangeStart, $rangeEnd, $fixedStart, $fixedValueForMultiple): array { $start = $fixedValueForMultiple ? $fixedStart : Carbon::instance($this->faker->dateTimeBetween($rangeStart, $rangeEnd)); return [ 'start' => $start->utc(), 'end' => $this->faker->dateTimeBetween($start, 'now'), ]; }); } public function active(): self { return $this->state(function (array $attributes): array { return [ 'end' => null, ]; }); } /** * @deprecated Use forMember instead */ public function forUser(User $user): self { return $this->state(function (array $attributes) use ($user) { return [ 'user_id' => $user->getKey(), ]; }); } public function forMember(Member $member): static { return $this->state(function (array $attributes) use ($member): array { return [ 'member_id' => $member->getKey(), 'user_id' => $member->user_id, 'organization_id' => $member->organization_id, ]; }); } public function billable(): self { return $this->state(function (array $attributes): array { return [ 'billable' => true, ]; }); } public function startWithDuration(Carbon $start, int $durationInSeconds): self { return $this->state(function (array $attributes) use ($start, $durationInSeconds): array { return [ 'start' => $start->copy()->utc(), 'end' => $start->copy()->utc()->addSeconds($durationInSeconds), ]; }); } public function endWithDuration(Carbon $end, int $durationInSeconds): self { return $this->state(function (array $attributes) use ($end, $durationInSeconds): array { return [ 'start' => $end->copy()->utc()->subSeconds($durationInSeconds), 'end' => $end->copy()->utc(), ]; }); } public function start(Carbon $start): self { return $this->state(function (array $attributes) use ($start): array { return [ 'start' => $start->copy()->utc(), ]; }); } public function forOrganization(Organization $organization): self { return $this->state(function (array $attributes) use ($organization) { return [ 'organization_id' => $organization->getKey(), ]; }); } public function forProject(?Project $project): self { return $this->state(fn (array $attributes) => [ 'project_id' => $project?->getKey(), 'client_id' => $project?->client_id, ]); } public function forTask(?Task $task): self { return $this->state(fn (array $attributes) => [ 'task_id' => $task?->getKey(), 'project_id' => $task?->project?->getKey(), 'client_id' => $task?->project?->client?->getKey(), ]); } } ================================================ FILE: database/factories/UserFactory.php ================================================ */ class UserFactory extends Factory { /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'name' => $this->faker->name(), 'email' => $this->faker->unique()->safeEmail(), '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, ]; } public function forCurrentOrganization(Organization $organization): static { return $this->state(function (array $attributes) use ($organization): array { return [ 'current_team_id' => $organization->getKey(), ]; }); } public function randomTimeZone(): static { return $this->state(function (array $attributes) { return [ 'timezone' => $this->faker->timezone(), ]; }); } public function placeholder(bool $placeholder = true): static { return $this->state(function (array $attributes) use ($placeholder): array { return [ 'is_placeholder' => $placeholder, ]; }); } /** * Indicate that the model's email address should be unverified. */ public function unverified(): static { return $this->state(function (array $attributes) { return [ 'email_verified_at' => null, ]; }); } public function attachToOrganization(Organization $organization, array $pivot = []): static { return $this->afterCreating(function (User $user) use ($organization, $pivot): void { $user->organizations()->attach($organization, $pivot); }); } public function withProfilePicture(): static { $profilePhoto = $this->faker->image(null, 500, 500); /** @see \Illuminate\Http\FileHelpers::hashName */ $path = 'profile-photos/'.Str::random(40).'.png'; Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto); return $this->state(function (array $attributes) use ($path): array { return [ 'profile_photo_path' => $path, ]; }); } /** * Indicate that the user should have a personal team. */ public function withPersonalOrganization(?callable $callback = null): static { return $this->afterCreating(function (User $user) use ($callback): void { $organization = Organization::factory() ->state(fn (array $attributes) => [ 'name' => $user->name.'\'s Organization', 'user_id' => $user->id, 'personal_team' => true, ]) ->when(is_callable($callback), $callback) ->create(); $organization->owner()->associate($user); $organization->users()->attach($user, ['role' => Role::Owner->value]); $user->currentTeam()->associate($organization); $user->save(); }); } } ================================================ FILE: database/migrations/2014_10_12_000000_create_users_table.php ================================================ uuid('id')->primary(); $table->string('name'); $table->string('email'); $table->timestamp('email_verified_at')->nullable(); $table->string('password')->nullable(); $table->rememberToken(); $table->boolean('is_placeholder')->default(false); $table->foreignUuid('current_team_id')->nullable(); $table->string('profile_photo_path', 2048)->nullable(); $table->string('timezone'); $table->enum('week_start', [ 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', ]); $table->timestamps(); $table->uniqueIndex('email') ->where('is_placeholder = false'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('users'); } }; ================================================ FILE: database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php ================================================ string('email')->primary(); $table->string('token'); $table->timestamp('created_at')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('password_reset_tokens'); } }; ================================================ FILE: database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php ================================================ text('two_factor_secret') ->after('password') ->nullable(); $table->text('two_factor_recovery_codes') ->after('two_factor_secret') ->nullable(); if (Fortify::confirmsTwoFactorAuthentication()) { $table->timestamp('two_factor_confirmed_at') ->after('two_factor_recovery_codes') ->nullable(); } }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('users', function (Blueprint $table): void { $table->dropColumn(array_merge([ 'two_factor_secret', 'two_factor_recovery_codes', ], Fortify::confirmsTwoFactorAuthentication() ? [ 'two_factor_confirmed_at', ] : [])); }); } }; ================================================ FILE: database/migrations/2016_06_01_000001_create_oauth_auth_codes_table.php ================================================ string('id', 100)->primary(); $table->foreignUuid('user_id')->index(); $table->uuid('client_id'); $table->text('scopes')->nullable(); $table->boolean('revoked'); $table->dateTime('expires_at')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('oauth_auth_codes'); } }; ================================================ FILE: database/migrations/2016_06_01_000002_create_oauth_access_tokens_table.php ================================================ string('id', 100)->primary(); $table->foreignUuid('user_id')->nullable()->index(); $table->uuid('client_id'); $table->string('name')->nullable(); $table->text('scopes')->nullable(); $table->boolean('revoked'); $table->timestamps(); $table->dateTime('expires_at')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('oauth_access_tokens'); } }; ================================================ FILE: database/migrations/2016_06_01_000003_create_oauth_refresh_tokens_table.php ================================================ string('id', 100)->primary(); $table->string('access_token_id', 100)->index(); $table->boolean('revoked'); $table->dateTime('expires_at')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('oauth_refresh_tokens'); } }; ================================================ FILE: database/migrations/2016_06_01_000004_create_oauth_clients_table.php ================================================ uuid('id')->primary(); $table->foreignUuid('user_id')->nullable()->index(); $table->string('name'); $table->string('secret', 100)->nullable(); $table->string('provider')->nullable(); $table->text('redirect'); $table->boolean('personal_access_client'); $table->boolean('password_client'); $table->boolean('revoked'); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('oauth_clients'); } }; ================================================ FILE: database/migrations/2016_06_01_000005_create_oauth_personal_access_clients_table.php ================================================ bigIncrements('id'); $table->uuid('client_id'); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('oauth_personal_access_clients'); } }; ================================================ FILE: database/migrations/2018_08_08_100000_create_telescope_entries_table.php ================================================ getConnection()); $schema->create('telescope_entries', function (Blueprint $table): void { $table->bigIncrements('sequence'); $table->uuid('uuid'); $table->uuid('batch_id'); $table->string('family_hash')->nullable(); $table->boolean('should_display_on_index')->default(true); $table->string('type', 20); $table->longText('content'); $table->dateTime('created_at')->nullable(); $table->unique('uuid'); $table->index('batch_id'); $table->index('family_hash'); $table->index('created_at'); $table->index(['type', 'should_display_on_index']); }); $schema->create('telescope_entries_tags', function (Blueprint $table): void { $table->uuid('entry_uuid'); $table->string('tag'); $table->primary(['entry_uuid', 'tag']); $table->index('tag'); $table->foreign('entry_uuid') ->references('uuid') ->on('telescope_entries') ->onDelete('cascade'); }); $schema->create('telescope_monitoring', function (Blueprint $table): void { $table->string('tag')->primary(); }); } /** * Reverse the migrations. */ public function down(): void { if (! App::isLocal()) { return; } $schema = Schema::connection($this->getConnection()); $schema->dropIfExists('telescope_entries_tags'); $schema->dropIfExists('telescope_entries'); $schema->dropIfExists('telescope_monitoring'); } }; ================================================ FILE: database/migrations/2019_08_19_000000_create_failed_jobs_table.php ================================================ uuid('id')->primary(); $table->uuid('uuid')->unique(); $table->text('connection'); $table->text('queue'); $table->longText('payload'); $table->longText('exception'); $table->timestamp('failed_at')->useCurrent(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('failed_jobs'); } }; ================================================ FILE: database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php ================================================ uuid('id')->primary(); $table->morphs('tokenable'); $table->string('name'); $table->string('token', 64)->unique(); $table->text('abilities')->nullable(); $table->timestamp('last_used_at')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('personal_access_tokens'); } }; ================================================ FILE: database/migrations/2020_05_21_100000_create_organizations_table.php ================================================ uuid('id')->primary(); $table->foreignUuid('user_id')->index(); $table->string('name'); $table->boolean('personal_team'); $table->integer('billable_rate')->unsigned()->nullable(); $table->string('currency', 3); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('organizations'); } }; ================================================ FILE: database/migrations/2020_05_21_200000_create_organization_user_table.php ================================================ uuid('id')->primary(); $table->foreignUuid('organization_id'); $table->foreignUuid('user_id'); $table->string('role')->nullable(); $table->integer('billable_rate')->unsigned()->nullable(); $table->timestamps(); $table->unique(['organization_id', 'user_id']); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('organization_user'); } }; ================================================ FILE: database/migrations/2020_05_21_300000_create_organization_invitations_table.php ================================================ uuid('id')->primary(); $table->foreignUuid('organization_id') ->constrained() ->cascadeOnDelete(); $table->string('email'); $table->string('role')->nullable(); $table->timestamps(); $table->unique(['organization_id', 'email']); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('organization_invitations'); } }; ================================================ FILE: database/migrations/2024_01_16_161030_create_sessions_table.php ================================================ string('id')->primary(); $table->foreignUuid('user_id')->nullable()->index(); $table->string('ip_address', 45)->nullable(); $table->text('user_agent')->nullable(); $table->longText('payload'); $table->integer('last_activity')->index(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('sessions'); } }; ================================================ FILE: database/migrations/2024_01_20_110218_create_clients_table.php ================================================ uuid('id')->primary(); $table->string('name', 255); $table->uuid('organization_id'); $table->foreign('organization_id') ->references('id') ->on('organizations') ->cascadeOnUpdate() ->restrictOnDelete(); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('clients'); } }; ================================================ FILE: database/migrations/2024_01_20_110439_create_projects_table.php ================================================ uuid('id')->primary(); $table->string('name', 255); $table->string('color', 16); $table->integer('billable_rate')->unsigned()->nullable(); $table->boolean('is_public')->default(false); $table->uuid('client_id')->nullable(); $table->foreign('client_id') ->references('id') ->on('clients') ->cascadeOnUpdate() ->restrictOnDelete(); $table->uuid('organization_id'); $table->foreign('organization_id') ->references('id') ->on('organizations') ->cascadeOnUpdate() ->restrictOnDelete(); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('projects'); } }; ================================================ FILE: database/migrations/2024_01_20_110444_create_tasks_table.php ================================================ uuid('id')->primary(); $table->string('name', 500); $table->uuid('project_id'); $table->foreign('project_id') ->references('id') ->on('projects') ->cascadeOnUpdate() ->restrictOnDelete(); $table->uuid('organization_id'); $table->foreign('organization_id') ->references('id') ->on('organizations') ->cascadeOnUpdate() ->restrictOnDelete(); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('tasks'); } }; ================================================ FILE: database/migrations/2024_01_20_110452_create_tags_table.php ================================================ uuid('id')->primary(); $table->string('name', 255); $table->uuid('organization_id'); $table->foreign('organization_id') ->references('id') ->on('organizations') ->cascadeOnUpdate() ->restrictOnDelete(); $table->timestamps(); $table->index('created_at'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('tags'); } }; ================================================ FILE: database/migrations/2024_01_20_110837_create_time_entries_table.php ================================================ uuid('id')->primary(); $table->string('description', 500); $table->dateTime('start'); $table->dateTime('end')->nullable(); $table->integer('billable_rate')->unsigned()->nullable(); $table->boolean('billable')->default(false); $table->uuid('user_id'); $table->foreign('user_id') ->references('id') ->on('users') ->cascadeOnUpdate() ->restrictOnDelete(); $table->uuid('organization_id'); $table->foreign('organization_id') ->references('id') ->on('organizations') ->cascadeOnUpdate() ->restrictOnDelete(); $table->uuid('project_id')->nullable(); $table->foreign('project_id') ->references('id') ->on('projects') ->cascadeOnUpdate() ->restrictOnDelete(); $table->uuid('task_id')->nullable(); $table->foreign('task_id') ->references('id') ->on('tasks') ->cascadeOnUpdate() ->restrictOnDelete(); $table->jsonb('tags')->nullable(); $table->timestamps(); $table->index('start'); $table->index('end'); $table->index('billable'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('time_entries'); } }; ================================================ FILE: database/migrations/2024_03_26_171253_create_project_members_table.php ================================================ uuid('id')->primary(); $table->integer('billable_rate')->unsigned()->nullable(); $table->uuid('project_id'); $table->foreign('project_id') ->references('id') ->on('projects') ->restrictOnDelete() ->cascadeOnUpdate(); $table->uuid('user_id'); $table->foreign('user_id') ->references('id') ->on('users') ->restrictOnDelete() ->cascadeOnUpdate(); $table->timestamps(); $table->unique(['project_id', 'user_id']); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('project_members'); } }; ================================================ FILE: database/migrations/2024_04_11_150130_create_jobs_table.php ================================================ bigIncrements('id'); $table->string('queue')->index(); $table->longText('payload'); $table->unsignedTinyInteger('attempts'); $table->unsignedInteger('reserved_at')->nullable(); $table->unsignedInteger('available_at'); $table->unsignedInteger('created_at'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('jobs'); } }; ================================================ FILE: database/migrations/2024_04_12_095010_create_cache_table.php ================================================ string('key')->primary(); $table->mediumText('value'); $table->integer('expiration'); }); Schema::create('cache_locks', function (Blueprint $table): void { $table->string('key')->primary(); $table->string('owner'); $table->integer('expiration'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('cache'); Schema::dropIfExists('cache_locks'); } }; ================================================ FILE: database/migrations/2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table.php ================================================ foreignUuid('member_id') ->nullable() ->constrained('organization_user') ->cascadeOnDelete() ->cascadeOnUpdate(); }); DB::statement(' update project_members set member_id = organization_user.id from projects join organization_user on organization_user.organization_id = projects.organization_id where projects.id = project_members.project_id and project_members.user_id = organization_user.user_id '); Schema::table('project_members', function (Blueprint $table): void { $table->uuid('member_id')->nullable(false)->change(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('project_members', function (Blueprint $table): void { $table->dropForeign(['member_id']); $table->dropColumn('member_id'); }); } }; ================================================ FILE: database/migrations/2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table.php ================================================ foreignUuid('member_id') ->nullable() ->constrained('organization_user') ->cascadeOnDelete() ->cascadeOnUpdate(); }); DB::statement(' update time_entries set member_id = organization_user.id from organization_user where time_entries.organization_id = organization_user.organization_id and time_entries.user_id = organization_user.user_id '); Schema::table('time_entries', function (Blueprint $table): void { $table->uuid('member_id')->nullable(false)->change(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('time_entries', function (Blueprint $table): void { $table->dropForeign(['member_id']); $table->dropColumn('member_id'); }); } }; ================================================ FILE: database/migrations/2024_05_13_171020_rename_table_organization_user_to_members.php ================================================ foreignUuid('client_id') ->nullable() ->constrained('clients') ->cascadeOnDelete() ->cascadeOnUpdate(); }); DB::statement(' update time_entries set client_id = clients.id from projects join clients on projects.client_id = clients.id where time_entries.project_id = projects.id '); } /** * Reverse the migrations. */ public function down(): void { Schema::table('time_entries', function (Blueprint $table): void { $table->dropForeign(['client_id']); $table->dropColumn('client_id'); }); } }; ================================================ FILE: database/migrations/2024_05_30_175801_add_is_billable_column_to_projects_table.php ================================================ boolean('is_billable')->default(false); }); DB::statement(' update projects set is_billable = true where projects.billable_rate is not null and projects.billable_rate > 0 '); Schema::table('projects', function (Blueprint $table): void { $table->boolean('is_billable')->default(null)->change(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('projects', function (Blueprint $table): void { $table->dropColumn('is_billable'); }); } }; ================================================ FILE: database/migrations/2024_05_30_175825_add_is_imported_column_to_time_entries_table.php ================================================ boolean('is_imported')->default(false); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('time_entries', function (Blueprint $table): void { $table->dropColumn('is_imported'); }); } }; ================================================ FILE: database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php ================================================ 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'); } }; ================================================ FILE: database/migrations/2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete.php ================================================ dropForeign(['member_id']); $table->foreign('member_id') ->references('id') ->on('members') ->restrictOnDelete() ->cascadeOnUpdate(); $table->dropForeign(['client_id']); $table->foreign('client_id') ->references('id') ->on('clients') ->restrictOnDelete() ->cascadeOnUpdate(); }); Schema::table('project_members', function (Blueprint $table): void { $table->dropForeign(['member_id']); $table->foreign('member_id') ->references('id') ->on('members') ->restrictOnDelete() ->cascadeOnUpdate(); }); Schema::table('organization_invitations', function (Blueprint $table): void { $table->dropForeign(['organization_id']); $table->foreign('organization_id') ->references('id') ->on('organizations') ->restrictOnDelete() ->cascadeOnUpdate(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('time_entries', function (Blueprint $table): void { $table->dropForeign(['member_id']); $table->foreign('member_id') ->references('id') ->on('members') ->cascadeOnDelete() ->cascadeOnUpdate(); $table->dropForeign(['client_id']); $table->foreign('client_id') ->references('id') ->on('clients') ->cascadeOnDelete() ->cascadeOnUpdate(); }); Schema::table('project_members', function (Blueprint $table): void { $table->dropForeign(['member_id']); $table->foreign('member_id') ->references('id') ->on('members') ->cascadeOnDelete() ->cascadeOnUpdate(); }); Schema::table('organization_invitations', function (Blueprint $table): void { $table->dropForeign(['organization_id']); $table->foreign('organization_id') ->references('id') ->on('organizations') ->cascadeOnDelete() ->cascadeOnUpdate(); }); } }; ================================================ FILE: database/migrations/2024_06_10_161831_reset_billable_rates_with_zero_as_value.php ================================================ where('billable_rate', '=', 0) ->update(['billable_rate' => null]); DB::table('project_members') ->where('billable_rate', '=', 0) ->update(['billable_rate' => null]); DB::table('projects') ->where('billable_rate', '=', 0) ->update(['billable_rate' => null]); DB::table('members') ->where('billable_rate', '=', 0) ->update(['billable_rate' => null]); DB::table('time_entries') ->where('billable_rate', '=', 0) ->update(['billable_rate' => null]); } /** * Reverse the migrations. */ public function down(): void { // } }; ================================================ FILE: database/migrations/2024_06_21_122754_add_is_archived_columns_to_projects_and_clients_table.php ================================================ dateTime('archived_at')->nullable(); }); Schema::table('clients', function (Blueprint $table): void { $table->dateTime('archived_at')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('projects', function (Blueprint $table): void { $table->dropColumn('archived_at'); }); Schema::table('clients', function (Blueprint $table): void { $table->dropColumn('archived_at'); }); } }; ================================================ FILE: database/migrations/2024_06_24_114433_add_done_at_to_tasks_table.php ================================================ dateTime('done_at')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('tasks', function (Blueprint $table): void { $table->dropColumn('done_at'); }); } }; ================================================ FILE: database/migrations/2024_07_02_134307_add_estimated_time_to_projects_and_tasks_table.php ================================================ integer('estimated_time')->unsigned()->nullable(); }); Schema::table('tasks', function (Blueprint $table): void { $table->integer('estimated_time')->unsigned()->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('projects', function (Blueprint $table): void { $table->dropColumn('estimated_time'); }); Schema::table('tasks', function (Blueprint $table): void { $table->dropColumn('estimated_time'); }); } }; ================================================ FILE: database/migrations/2024_07_03_145445_change_data_type_of_id_column_in_failed_jobs_table.php ================================================ truncate(); Schema::table('failed_jobs', function (Blueprint $table): void { $table->dropColumn('id'); }); Schema::table('failed_jobs', function (Blueprint $table): void { $table->id(); }); } /** * Reverse the migrations. */ public function down(): void { DB::table('failed_jobs')->truncate(); Schema::table('failed_jobs', function (Blueprint $table): void { $table->dropColumn('id'); }); Schema::table('failed_jobs', function (Blueprint $table): void { $table->uuid('id')->primary(); }); } }; ================================================ FILE: database/migrations/2024_07_18_080906_add_still_active_email_sent_at_to_time_entries_table.php ================================================ dateTime('still_active_email_sent_at')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('time_entries', function (Blueprint $table): void { $table->dropColumn('still_active_email_sent_at'); }); } }; ================================================ FILE: database/migrations/2024_08_01_104840_create_reports_table.php ================================================ uuid('id')->primary(); $table->string('name'); $table->text('description')->nullable(); $table->boolean('is_public')->default(false)->index(); $table->string('share_secret', 40)->nullable()->index()->unique(); $table->jsonb('properties'); $table->dateTime('public_until')->nullable(); $table->uuid('organization_id'); $table->foreign('organization_id') ->references('id') ->on('organizations') ->restrictOnDelete() ->cascadeOnUpdate(); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('reports'); } }; ================================================ FILE: database/migrations/2024_09_02_094105_create_audits_table.php ================================================ create($table, function (Blueprint $table): void { $morphPrefix = config('audit.user.morph_prefix', 'user'); $table->bigIncrements('id'); $table->string($morphPrefix.'_type')->nullable(); $table->uuid($morphPrefix.'_id')->nullable(); $table->string('event'); $table->uuidMorphs('auditable'); $table->json('old_values')->nullable(); $table->json('new_values')->nullable(); $table->text('url')->nullable(); $table->ipAddress('ip_address')->nullable(); $table->string('user_agent', 1023)->nullable(); $table->string('tags')->nullable(); $table->timestamps(); $table->index([$morphPrefix.'_id', $morphPrefix.'_type']); }); } /** * Reverse the migrations. */ public function down(): void { $connection = config('audit.drivers.database.connection', config('database.default')); $table = config('audit.drivers.database.table', 'audits'); Schema::connection($connection)->drop($table); } } ================================================ FILE: database/migrations/2024_09_18_120203_add_spent_time_to_projects_and_tasks_table.php ================================================ integer('spent_time')->unsigned()->default(0); }); Schema::table('tasks', function (Blueprint $table): void { $table->integer('spent_time')->unsigned()->default(0); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('projects', function (Blueprint $table): void { $table->dropColumn('spent_time'); }); Schema::table('tasks', function (Blueprint $table): void { $table->dropColumn('spent_time'); }); } }; ================================================ FILE: database/migrations/2024_10_01_143608_add_employees_can_see_billable_rates_to_organizations_table.php ================================================ boolean('employees_can_see_billable_rates')->default(false); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('organizations', function (Blueprint $table): void { $table->dropColumn('employees_can_see_billable_rates'); }); } }; ================================================ FILE: database/migrations/2024_11_04_164807_add_foreign_key_to_organizations_and_members_table.php ================================================ select(['organizations.id', 'organizations.user_id']) ->whereNotExists(function (Builder $query): void { $query->select('id') ->from('users') ->whereColumn('organizations.user_id', 'users.id'); }) ->get(); foreach ($foreignKeyProblems as $foreignKeyProblem) { Log::error('Organization with ID '.$foreignKeyProblem->id.' has non-existing owner with ID '.$foreignKeyProblem->user_id); } if ($foreignKeyProblems->count() > 0) { throw new Exception('There are organizations with non-existing owners, check the logs for more information'); } $foreignKeyProblems = DB::table('members') ->select(['members.id', 'members.organization_id']) ->whereNotExists(function (Builder $query): void { $query->select('id') ->from('organizations') ->whereColumn('members.organization_id', 'organizations.id'); }) ->get(); foreach ($foreignKeyProblems as $foreignKeyProblem) { Log::error('Member with ID '.$foreignKeyProblem->id.' has non-existing organization with ID '.$foreignKeyProblem->organization_id); } if ($foreignKeyProblems->count() > 0) { throw new Exception('There are members with non-existing organizations, check the logs for more information'); } $foreignKeyProblems = DB::table('members') ->select(['members.id', 'members.user_id']) ->whereNotExists(function (Builder $query): void { $query->select('id') ->from('users') ->whereColumn('members.user_id', 'users.id'); }) ->get(); foreach ($foreignKeyProblems as $foreignKeyProblem) { Log::error('Member with ID '.$foreignKeyProblem->id.' has non-existing user with ID '.$foreignKeyProblem->user_id); } if ($foreignKeyProblems->count() > 0) { throw new Exception('There are members with non-existing users, check the logs for more information'); } Schema::table('organizations', function (Blueprint $table): void { $table->foreign('user_id') ->references('id') ->on('users') ->onDelete('restrict') ->onUpdate('cascade'); }); Schema::table('members', function (Blueprint $table): void { $table->foreign('organization_id') ->references('id') ->on('organizations') ->onDelete('restrict') ->onUpdate('cascade'); $table->foreign('user_id') ->references('id') ->on('users') ->onDelete('restrict') ->onUpdate('cascade'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('organizations', function (Blueprint $table): void { $table->dropForeign(['user_id']); }); Schema::table('members', function (Blueprint $table): void { $table->dropForeign(['organization_id']); $table->dropForeign(['user_id']); }); } }; ================================================ FILE: database/migrations/2024_11_04_170614_add_foreign_keys_to_oauth_tables.php ================================================ whereNotNull('user_id') ->whereNotExists(function (Builder $query): void { $query->select('id') ->from('users') ->whereColumn('oauth_access_tokens.user_id', 'users.id'); }) ->delete(); DB::table('oauth_access_tokens') ->whereNotExists(function (Builder $query): void { $query->select('id') ->from('oauth_clients') ->whereColumn('oauth_access_tokens.client_id', 'oauth_clients.id'); }) ->delete(); Schema::table('oauth_access_tokens', function (Blueprint $table): void { $table->foreign('user_id') ->references('id') ->on('users') ->onDelete('restrict') ->onUpdate('cascade'); $table->foreign('client_id') ->references('id') ->on('oauth_clients') ->onDelete('restrict') ->onUpdate('cascade'); }); DB::table('oauth_auth_codes') ->whereNotExists(function (Builder $query): void { $query->select('id') ->from('users') ->whereColumn('oauth_auth_codes.user_id', 'users.id'); }) ->delete(); DB::table('oauth_auth_codes') ->whereNotExists(function (Builder $query): void { $query->select('id') ->from('oauth_clients') ->whereColumn('oauth_auth_codes.client_id', 'oauth_clients.id'); }) ->delete(); Schema::table('oauth_auth_codes', function (Blueprint $table): void { $table->foreign('user_id') ->references('id') ->on('users') ->onDelete('restrict') ->onUpdate('cascade'); $table->foreign('client_id') ->references('id') ->on('oauth_clients') ->onDelete('restrict') ->onUpdate('cascade'); }); DB::table('oauth_clients') ->whereNotNull('user_id') ->whereNotExists(function (Builder $query): void { $query->select('id') ->from('users') ->whereColumn('oauth_clients.user_id', 'users.id'); }) ->delete(); Schema::table('oauth_clients', function (Blueprint $table): void { $table->foreign('user_id') ->references('id') ->on('users') ->onDelete('restrict') ->onUpdate('cascade'); }); Schema::table('oauth_personal_access_clients', function (Blueprint $table): void { $table->foreign('client_id') ->references('id') ->on('oauth_clients') ->onDelete('restrict') ->onUpdate('cascade'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('oauth_access_tokens', function (Blueprint $table): void { $table->dropForeign(['user_id']); $table->dropForeign(['client_id']); }); Schema::table('oauth_auth_codes', function (Blueprint $table): void { $table->dropForeign(['user_id']); $table->dropForeign(['client_id']); }); Schema::table('oauth_clients', function (Blueprint $table): void { $table->dropForeign(['user_id']); }); Schema::table('oauth_personal_access_clients', function (Blueprint $table): void { $table->dropForeign(['client_id']); }); } }; ================================================ FILE: database/migrations/2025_04_03_101827_add_localization_columns_to_organizations_table.php ================================================ string('number_format')->default(config('app.localization.default_number_format'))->nullable(false); $table->string('currency_format')->default(config('app.localization.default_currency_format'))->nullable(false); $table->string('date_format')->default(config('app.localization.default_date_format'))->nullable(false); $table->string('interval_format')->default(config('app.localization.default_interval_format'))->nullable(false); $table->string('time_format')->default(config('app.localization.default_time_format'))->nullable(false); }); Schema::table('organizations', function (Blueprint $table): void { $table->string('number_format')->default(null)->nullable(false)->change(); $table->string('currency_format')->default(null)->nullable(false)->change(); $table->string('date_format')->default(null)->nullable(false)->change(); $table->string('interval_format')->default(null)->nullable(false)->change(); $table->string('time_format')->default(null)->nullable(false)->change(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('organizations', function (Blueprint $table): void { $table->dropColumn('number_format'); $table->dropColumn('currency_format'); $table->dropColumn('date_format'); $table->dropColumn('interval_format'); $table->dropColumn('time_format'); }); } }; ================================================ FILE: database/migrations/2025_04_25_202047_change_data_type_for_spent_time_columns.php ================================================ bigInteger('spent_time')->unsigned()->default(0)->change(); }); Schema::table('tasks', function (Blueprint $table): void { $table->bigInteger('spent_time')->unsigned()->default(0)->change(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('projects', function (Blueprint $table): void { $table->integer('spent_time')->unsigned()->default(0)->change(); }); Schema::table('tasks', function (Blueprint $table): void { $table->integer('spent_time')->unsigned()->default(0)->change(); }); } }; ================================================ FILE: database/migrations/2025_05_06_152804_fix_typos_in_organizations_table_format_columns.php ================================================ foreign('current_team_id', 'organizations_current_organization_id_foreign') ->references('id') ->on('organizations') ->onDelete('restrict') ->onUpdate('cascade'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('users', function (Blueprint $table): void { $table->dropForeign('organizations_current_organization_id_foreign'); }); } }; ================================================ FILE: database/migrations/2025_06_30_095942_remove_oauth_personal_access_clients_table.php ================================================ bigIncrements('id'); $table->uuid('client_id'); $table->foreign('client_id') ->references('id') ->on('oauth_clients') ->onDelete('restrict') ->onUpdate('cascade'); $table->timestamps(); }); } }; ================================================ FILE: database/migrations/2025_06_30_132538_update_oauth_clients_table.php ================================================ 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(); }); } }; ================================================ FILE: database/migrations/2025_07_15_105949_hash_oauth_clients.php ================================================ 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. } }; ================================================ FILE: database/migrations/2025_07_17_104903_add_reminder_sent_at_to_oauth_access_tokens_table.php ================================================ 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'); }); } }; ================================================ FILE: database/migrations/2025_10_02_000001_add_prevent_overlapping_time_entries_to_organizations_table.php ================================================ boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('organizations', function (Blueprint $table): void { $table->dropColumn('prevent_overlapping_time_entries'); }); } }; ================================================ FILE: database/migrations/2025_10_16_000001_extend_time_entry_description.php ================================================ string('description', 5000)->change(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('time_entries', function (Blueprint $table): void { $table->string('description', 500)->change(); }); } }; ================================================ FILE: database/migrations/2025_10_24_120845_add_employees_can_manage_tasks_to_organizations_table.php ================================================ boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('organizations', function (Blueprint $table): void { $table->dropColumn('employees_can_manage_tasks'); }); } }; ================================================ FILE: database/schema/pgsql_test-schema.sql ================================================ -- -- PostgreSQL database dump -- -- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2) -- Dumped by pg_dump version 15.7 (Ubuntu 15.7-1.pgdg22.04+1) SET statement_timeout = 0; SET lock_timeout = 0; SET idle_in_transaction_session_timeout = 0; SET client_encoding = 'UTF8'; SET standard_conforming_strings = on; SELECT pg_catalog.set_config('search_path', '', false); SET check_function_bodies = false; SET xmloption = content; SET client_min_messages = warning; SET row_security = off; SET default_tablespace = ''; SET default_table_access_method = heap; -- -- Name: cache; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.cache ( key character varying(255) NOT NULL, value text NOT NULL, expiration integer NOT NULL ); -- -- Name: cache_locks; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.cache_locks ( key character varying(255) NOT NULL, owner character varying(255) NOT NULL, expiration integer NOT NULL ); -- -- Name: clients; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.clients ( id uuid NOT NULL, name character varying(255) NOT NULL, organization_id uuid NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: customers; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.customers ( id uuid NOT NULL, billable_id uuid NOT NULL, billable_type character varying(255) NOT NULL, paddle_id character varying(255) NOT NULL, name character varying(255) NOT NULL, email character varying(255) NOT NULL, trial_ends_at timestamp(0) without time zone, pending_checkout_id character varying(255), created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: failed_jobs; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.failed_jobs ( id uuid NOT NULL, uuid uuid NOT NULL, connection text NOT NULL, queue text NOT NULL, payload text NOT NULL, exception text NOT NULL, failed_at timestamp(0) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL ); -- -- Name: jobs; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.jobs ( id bigint NOT NULL, queue character varying(255) NOT NULL, payload text NOT NULL, attempts smallint NOT NULL, reserved_at integer, available_at integer NOT NULL, created_at integer NOT NULL ); -- -- Name: jobs_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- CREATE SEQUENCE public.jobs_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; -- -- Name: jobs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - -- ALTER SEQUENCE public.jobs_id_seq OWNED BY public.jobs.id; -- -- Name: members; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.members ( id uuid NOT NULL, organization_id uuid NOT NULL, user_id uuid NOT NULL, role character varying(255), billable_rate integer, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: migrations; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.migrations ( id integer NOT NULL, migration character varying(255) NOT NULL, batch integer NOT NULL ); -- -- Name: migrations_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- CREATE SEQUENCE public.migrations_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; -- -- Name: migrations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - -- ALTER SEQUENCE public.migrations_id_seq OWNED BY public.migrations.id; -- -- Name: oauth_access_tokens; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.oauth_access_tokens ( id character varying(100) NOT NULL, user_id uuid, client_id uuid NOT NULL, name character varying(255), scopes text, revoked boolean NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone, expires_at timestamp(0) without time zone ); -- -- Name: oauth_auth_codes; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.oauth_auth_codes ( id character varying(100) NOT NULL, user_id uuid NOT NULL, client_id uuid NOT NULL, scopes text, revoked boolean NOT NULL, expires_at timestamp(0) without time zone ); -- -- Name: oauth_clients; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.oauth_clients ( id uuid NOT NULL, user_id uuid, name character varying(255) NOT NULL, secret character varying(100), provider character varying(255), redirect text NOT NULL, personal_access_client boolean NOT NULL, password_client boolean NOT NULL, revoked boolean NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: oauth_personal_access_clients; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.oauth_personal_access_clients ( id bigint NOT NULL, client_id uuid NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: oauth_personal_access_clients_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- CREATE SEQUENCE public.oauth_personal_access_clients_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; -- -- Name: oauth_personal_access_clients_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - -- ALTER SEQUENCE public.oauth_personal_access_clients_id_seq OWNED BY public.oauth_personal_access_clients.id; -- -- Name: oauth_refresh_tokens; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.oauth_refresh_tokens ( id character varying(100) NOT NULL, access_token_id character varying(100) NOT NULL, revoked boolean NOT NULL, expires_at timestamp(0) without time zone ); -- -- Name: organization_invitations; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.organization_invitations ( id uuid NOT NULL, organization_id uuid NOT NULL, email character varying(255) NOT NULL, role character varying(255), created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: organizations; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.organizations ( id uuid NOT NULL, user_id uuid NOT NULL, name character varying(255) NOT NULL, personal_team boolean NOT NULL, billable_rate integer, currency character varying(3) NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: password_reset_tokens; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.password_reset_tokens ( email character varying(255) NOT NULL, token character varying(255) NOT NULL, created_at timestamp(0) without time zone ); -- -- Name: personal_access_tokens; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.personal_access_tokens ( id uuid NOT NULL, tokenable_type character varying(255) NOT NULL, tokenable_id bigint NOT NULL, name character varying(255) NOT NULL, token character varying(64) NOT NULL, abilities text, last_used_at timestamp(0) without time zone, expires_at timestamp(0) without time zone, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: project_members; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.project_members ( id uuid NOT NULL, billable_rate integer, project_id uuid NOT NULL, user_id uuid NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone, member_id uuid NOT NULL ); -- -- Name: projects; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.projects ( id uuid NOT NULL, name character varying(255) NOT NULL, color character varying(16) NOT NULL, billable_rate integer, is_public boolean DEFAULT false NOT NULL, client_id uuid, organization_id uuid NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone, is_billable boolean NOT NULL ); -- -- Name: sessions; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.sessions ( id character varying(255) NOT NULL, user_id uuid, ip_address character varying(45), user_agent text, payload text NOT NULL, last_activity integer NOT NULL ); -- -- Name: subscription_items; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.subscription_items ( id uuid NOT NULL, subscription_id uuid NOT NULL, product_id character varying(255) NOT NULL, price_id character varying(255) NOT NULL, status character varying(255) NOT NULL, quantity integer NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: subscriptions; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.subscriptions ( id uuid NOT NULL, billable_id uuid NOT NULL, billable_type character varying(255) NOT NULL, type character varying(255) NOT NULL, paddle_id character varying(255) NOT NULL, status character varying(255) NOT NULL, trial_ends_at timestamp(0) without time zone, paused_at timestamp(0) without time zone, ends_at timestamp(0) without time zone, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: tags; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.tags ( id uuid NOT NULL, name character varying(255) NOT NULL, organization_id uuid NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: tasks; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.tasks ( id uuid NOT NULL, name character varying(500) NOT NULL, project_id uuid NOT NULL, organization_id uuid NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: time_entries; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.time_entries ( id uuid NOT NULL, description character varying(5000) NOT NULL, start timestamp(0) without time zone NOT NULL, "end" timestamp(0) without time zone, billable_rate integer, billable boolean DEFAULT false NOT NULL, user_id uuid NOT NULL, organization_id uuid NOT NULL, project_id uuid, task_id uuid, tags jsonb, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone, member_id uuid NOT NULL, client_id uuid, is_imported boolean DEFAULT false NOT NULL ); -- -- Name: transactions; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.transactions ( id uuid NOT NULL, billable_id uuid NOT NULL, billable_type character varying(255) NOT NULL, paddle_id character varying(255) NOT NULL, paddle_subscription_id character varying(255), invoice_number character varying(255), status character varying(255) NOT NULL, total character varying(255) NOT NULL, tax character varying(255) NOT NULL, currency character varying(3) NOT NULL, billed_at timestamp(0) without time zone NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone ); -- -- Name: users; Type: TABLE; Schema: public; Owner: - -- CREATE TABLE public.users ( id uuid NOT NULL, name character varying(255) NOT NULL, email character varying(255) NOT NULL, email_verified_at timestamp(0) without time zone, password character varying(255), remember_token character varying(100), is_placeholder boolean DEFAULT false NOT NULL, current_team_id uuid, profile_photo_path character varying(2048), timezone character varying(255) NOT NULL, week_start character varying(255) NOT NULL, created_at timestamp(0) without time zone, updated_at timestamp(0) without time zone, two_factor_secret text, two_factor_recovery_codes text, two_factor_confirmed_at timestamp(0) without time zone, CONSTRAINT users_week_start_check CHECK (((week_start)::text = ANY ((ARRAY['monday'::character varying, 'tuesday'::character varying, 'wednesday'::character varying, 'thursday'::character varying, 'friday'::character varying, 'saturday'::character varying, 'sunday'::character varying])::text[]))) ); -- -- Name: jobs id; Type: DEFAULT; Schema: public; Owner: - -- ALTER TABLE ONLY public.jobs ALTER COLUMN id SET DEFAULT nextval('public.jobs_id_seq'::regclass); -- -- Name: migrations id; Type: DEFAULT; Schema: public; Owner: - -- ALTER TABLE ONLY public.migrations ALTER COLUMN id SET DEFAULT nextval('public.migrations_id_seq'::regclass); -- -- Name: oauth_personal_access_clients id; Type: DEFAULT; Schema: public; Owner: - -- ALTER TABLE ONLY public.oauth_personal_access_clients ALTER COLUMN id SET DEFAULT nextval('public.oauth_personal_access_clients_id_seq'::regclass); -- -- Name: cache_locks cache_locks_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.cache_locks ADD CONSTRAINT cache_locks_pkey PRIMARY KEY (key); -- -- Name: cache cache_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.cache ADD CONSTRAINT cache_pkey PRIMARY KEY (key); -- -- Name: clients clients_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.clients ADD CONSTRAINT clients_pkey PRIMARY KEY (id); -- -- Name: customers customers_paddle_id_unique; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.customers ADD CONSTRAINT customers_paddle_id_unique UNIQUE (paddle_id); -- -- Name: customers customers_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.customers ADD CONSTRAINT customers_pkey PRIMARY KEY (id); -- -- Name: failed_jobs failed_jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.failed_jobs ADD CONSTRAINT failed_jobs_pkey PRIMARY KEY (id); -- -- Name: failed_jobs failed_jobs_uuid_unique; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.failed_jobs ADD CONSTRAINT failed_jobs_uuid_unique UNIQUE (uuid); -- -- Name: jobs jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.jobs ADD CONSTRAINT jobs_pkey PRIMARY KEY (id); -- -- Name: migrations migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.migrations ADD CONSTRAINT migrations_pkey PRIMARY KEY (id); -- -- Name: oauth_access_tokens oauth_access_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.oauth_access_tokens ADD CONSTRAINT oauth_access_tokens_pkey PRIMARY KEY (id); -- -- Name: oauth_auth_codes oauth_auth_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.oauth_auth_codes ADD CONSTRAINT oauth_auth_codes_pkey PRIMARY KEY (id); -- -- Name: oauth_clients oauth_clients_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.oauth_clients ADD CONSTRAINT oauth_clients_pkey PRIMARY KEY (id); -- -- Name: oauth_personal_access_clients oauth_personal_access_clients_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.oauth_personal_access_clients ADD CONSTRAINT oauth_personal_access_clients_pkey PRIMARY KEY (id); -- -- Name: oauth_refresh_tokens oauth_refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.oauth_refresh_tokens ADD CONSTRAINT oauth_refresh_tokens_pkey PRIMARY KEY (id); -- -- Name: organization_invitations organization_invitations_organization_id_email_unique; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.organization_invitations ADD CONSTRAINT organization_invitations_organization_id_email_unique UNIQUE (organization_id, email); -- -- Name: organization_invitations organization_invitations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.organization_invitations ADD CONSTRAINT organization_invitations_pkey PRIMARY KEY (id); -- -- Name: members organization_user_organization_id_user_id_unique; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.members ADD CONSTRAINT organization_user_organization_id_user_id_unique UNIQUE (organization_id, user_id); -- -- Name: members organization_user_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.members ADD CONSTRAINT organization_user_pkey PRIMARY KEY (id); -- -- Name: organizations organizations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.organizations ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); -- -- Name: password_reset_tokens password_reset_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.password_reset_tokens ADD CONSTRAINT password_reset_tokens_pkey PRIMARY KEY (email); -- -- Name: personal_access_tokens personal_access_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.personal_access_tokens ADD CONSTRAINT personal_access_tokens_pkey PRIMARY KEY (id); -- -- Name: personal_access_tokens personal_access_tokens_token_unique; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.personal_access_tokens ADD CONSTRAINT personal_access_tokens_token_unique UNIQUE (token); -- -- Name: project_members project_members_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.project_members ADD CONSTRAINT project_members_pkey PRIMARY KEY (id); -- -- Name: project_members project_members_project_id_user_id_unique; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.project_members ADD CONSTRAINT project_members_project_id_user_id_unique UNIQUE (project_id, user_id); -- -- Name: projects projects_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.projects ADD CONSTRAINT projects_pkey PRIMARY KEY (id); -- -- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.sessions ADD CONSTRAINT sessions_pkey PRIMARY KEY (id); -- -- Name: subscription_items subscription_items_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.subscription_items ADD CONSTRAINT subscription_items_pkey PRIMARY KEY (id); -- -- Name: subscription_items subscription_items_subscription_id_price_id_unique; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.subscription_items ADD CONSTRAINT subscription_items_subscription_id_price_id_unique UNIQUE (subscription_id, price_id); -- -- Name: subscriptions subscriptions_paddle_id_unique; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.subscriptions ADD CONSTRAINT subscriptions_paddle_id_unique UNIQUE (paddle_id); -- -- Name: subscriptions subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.subscriptions ADD CONSTRAINT subscriptions_pkey PRIMARY KEY (id); -- -- Name: tags tags_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.tags ADD CONSTRAINT tags_pkey PRIMARY KEY (id); -- -- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.tasks ADD CONSTRAINT tasks_pkey PRIMARY KEY (id); -- -- Name: time_entries time_entries_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.time_entries ADD CONSTRAINT time_entries_pkey PRIMARY KEY (id); -- -- Name: transactions transactions_paddle_id_unique; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.transactions ADD CONSTRAINT transactions_paddle_id_unique UNIQUE (paddle_id); -- -- Name: transactions transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.transactions ADD CONSTRAINT transactions_pkey PRIMARY KEY (id); -- -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.users ADD CONSTRAINT users_pkey PRIMARY KEY (id); -- -- Name: customers_billable_id_billable_type_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX customers_billable_id_billable_type_index ON public.customers USING btree (billable_id, billable_type); -- -- Name: jobs_queue_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX jobs_queue_index ON public.jobs USING btree (queue); -- -- Name: oauth_access_tokens_user_id_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX oauth_access_tokens_user_id_index ON public.oauth_access_tokens USING btree (user_id); -- -- Name: oauth_auth_codes_user_id_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX oauth_auth_codes_user_id_index ON public.oauth_auth_codes USING btree (user_id); -- -- Name: oauth_clients_user_id_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX oauth_clients_user_id_index ON public.oauth_clients USING btree (user_id); -- -- Name: oauth_refresh_tokens_access_token_id_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX oauth_refresh_tokens_access_token_id_index ON public.oauth_refresh_tokens USING btree (access_token_id); -- -- Name: organizations_user_id_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX organizations_user_id_index ON public.organizations USING btree (user_id); -- -- Name: personal_access_tokens_tokenable_type_tokenable_id_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX personal_access_tokens_tokenable_type_tokenable_id_index ON public.personal_access_tokens USING btree (tokenable_type, tokenable_id); -- -- Name: sessions_last_activity_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX sessions_last_activity_index ON public.sessions USING btree (last_activity); -- -- Name: sessions_user_id_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX sessions_user_id_index ON public.sessions USING btree (user_id); -- -- Name: subscriptions_billable_id_billable_type_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX subscriptions_billable_id_billable_type_index ON public.subscriptions USING btree (billable_id, billable_type); -- -- Name: tags_created_at_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX tags_created_at_index ON public.tags USING btree (created_at); -- -- Name: time_entries_billable_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX time_entries_billable_index ON public.time_entries USING btree (billable); -- -- Name: time_entries_end_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX time_entries_end_index ON public.time_entries USING btree ("end"); -- -- Name: time_entries_start_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX time_entries_start_index ON public.time_entries USING btree (start); -- -- Name: transactions_billable_id_billable_type_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX transactions_billable_id_billable_type_index ON public.transactions USING btree (billable_id, billable_type); -- -- Name: transactions_paddle_subscription_id_index; Type: INDEX; Schema: public; Owner: - -- CREATE INDEX transactions_paddle_subscription_id_index ON public.transactions USING btree (paddle_subscription_id); -- -- Name: users_email_unique; Type: INDEX; Schema: public; Owner: - -- CREATE UNIQUE INDEX users_email_unique ON public.users USING btree (email) WHERE (is_placeholder = false); -- -- Name: clients clients_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.clients ADD CONSTRAINT clients_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: organization_invitations organization_invitations_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.organization_invitations ADD CONSTRAINT organization_invitations_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: project_members project_members_member_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.project_members ADD CONSTRAINT project_members_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: project_members project_members_project_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.project_members ADD CONSTRAINT project_members_project_id_foreign FOREIGN KEY (project_id) REFERENCES public.projects(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: project_members project_members_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.project_members ADD CONSTRAINT project_members_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: projects projects_client_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.projects ADD CONSTRAINT projects_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: projects projects_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.projects ADD CONSTRAINT projects_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: tags tags_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.tags ADD CONSTRAINT tags_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: tasks tasks_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.tasks ADD CONSTRAINT tasks_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: tasks tasks_project_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.tasks ADD CONSTRAINT tasks_project_id_foreign FOREIGN KEY (project_id) REFERENCES public.projects(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: time_entries time_entries_client_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.time_entries ADD CONSTRAINT time_entries_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: time_entries time_entries_member_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.time_entries ADD CONSTRAINT time_entries_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: time_entries time_entries_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.time_entries ADD CONSTRAINT time_entries_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: time_entries time_entries_project_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.time_entries ADD CONSTRAINT time_entries_project_id_foreign FOREIGN KEY (project_id) REFERENCES public.projects(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: time_entries time_entries_task_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.time_entries ADD CONSTRAINT time_entries_task_id_foreign FOREIGN KEY (task_id) REFERENCES public.tasks(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- Name: time_entries time_entries_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- ALTER TABLE ONLY public.time_entries ADD CONSTRAINT time_entries_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE RESTRICT; -- -- PostgreSQL database dump complete -- -- -- PostgreSQL database dump -- -- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2) -- Dumped by pg_dump version 15.7 (Ubuntu 15.7-1.pgdg22.04+1) SET statement_timeout = 0; SET lock_timeout = 0; SET idle_in_transaction_session_timeout = 0; SET client_encoding = 'UTF8'; SET standard_conforming_strings = on; SELECT pg_catalog.set_config('search_path', '', false); SET check_function_bodies = false; SET xmloption = content; SET client_min_messages = warning; SET row_security = off; -- -- Data for Name: migrations; Type: TABLE DATA; Schema: public; Owner: - -- COPY public.migrations (id, migration, batch) FROM stdin; 1 2014_10_12_000000_create_users_table 1 2 2014_10_12_100000_create_password_reset_tokens_table 1 3 2014_10_12_200000_add_two_factor_columns_to_users_table 1 4 2016_06_01_000001_create_oauth_auth_codes_table 1 5 2016_06_01_000002_create_oauth_access_tokens_table 1 6 2016_06_01_000003_create_oauth_refresh_tokens_table 1 7 2016_06_01_000004_create_oauth_clients_table 1 8 2016_06_01_000005_create_oauth_personal_access_clients_table 1 9 2018_08_08_100000_create_telescope_entries_table 1 10 2019_05_03_000001_create_customers_table 1 11 2019_05_03_000002_create_subscriptions_table 1 12 2019_05_03_000003_create_subscription_items_table 1 13 2019_05_03_000004_create_transactions_table 1 14 2019_08_19_000000_create_failed_jobs_table 1 15 2019_12_14_000001_create_personal_access_tokens_table 1 16 2020_05_21_100000_create_organizations_table 1 17 2020_05_21_200000_create_organization_user_table 1 18 2020_05_21_300000_create_organization_invitations_table 1 19 2024_01_16_161030_create_sessions_table 1 20 2024_01_20_110218_create_clients_table 1 21 2024_01_20_110439_create_projects_table 1 22 2024_01_20_110444_create_tasks_table 1 23 2024_01_20_110452_create_tags_table 1 24 2024_01_20_110837_create_time_entries_table 1 25 2024_03_26_171253_create_project_members_table 1 26 2024_04_11_150130_create_jobs_table 1 27 2024_04_12_095010_create_cache_table 1 28 2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table 1 29 2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table 1 30 2024_05_13_171020_rename_table_organization_user_to_members 1 31 2024_05_22_151226_add_client_id_to_time_entries_table 1 32 2024_05_30_175801_add_is_billable_column_to_projects_table 1 33 2024_05_30_175825_add_is_imported_column_to_time_entries_table 1 34 2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete 1 35 2024_06_10_161831_reset_billable_rates_with_zero_as_value 1 \. -- -- Name: migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - -- SELECT pg_catalog.setval('public.migrations_id_seq', 35, true); -- -- PostgreSQL database dump complete -- ================================================ FILE: database/seeders/DatabaseSeeder.php ================================================ deleteAll(); app(ClientRepository::class)->createAuthorizationCodeGrantClient( name: 'Desktop App', redirectUris: ['solidtime://oauth/callback'], confidential: false, // TODO: ? enableDeviceFlow: false, // TODO: ? ); // TODO: grant_types ? migration? // app(ClientRepository::class)->createPersonalAccessGrantClient('API'); /* app(ClientRepository::class)->create( null, 'desktop', 'solidtime://oauth/callback', null, false, false, false ); */ $personalAccessClient = new PassportClient; $personalAccessClient->id = config('passport.personal_access_client.id'); $personalAccessClient->secret = config('passport.personal_access_client.secret'); $personalAccessClient->name = 'API'; $personalAccessClient->redirect_uris = ['http://localhost']; $personalAccessClient->revoked = false; $personalAccessClient->provider = 'users'; $personalAccessClient->grant_types = ['personal_access']; $personalAccessClient->save(); $userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([ 'name' => 'Mister Overemployed', 'email' => 'overemployed@acme.test', ]); $userAcmeOwner = User::factory()->withPersonalOrganization()->create([ 'name' => 'Acme Owner', 'email' => 'owner@acme.test', ]); $organizationAcme = Organization::factory()->withOwner($userAcmeOwner)->create([ 'name' => 'ACME Corp', 'personal_team' => false, 'currency' => 'EUR', ]); OrganizationInvitation::factory()->forOrganization($organizationAcme)->create([ 'email' => 'new.employee@example.com', ]); $userAcmeManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Acme Manager', 'email' => 'test@example.com', ]); $userAcmeManager->createToken('Testing Token 1')->accessToken; $userAcmeManager->createToken('Testing Token 2')->accessToken; $userAcmeAdmin = User::factory()->withPersonalOrganization()->create([ 'name' => 'Acme Admin', 'email' => 'admin@acme.test', ]); $userAcmeEmployee = User::factory()->withPersonalOrganization()->create([ 'name' => 'Acme Employee', 'email' => 'max.mustermann@acme.test', ]); $userAcmePlaceholder = User::factory()->placeholder()->create([ 'name' => 'Acme Placeholder', 'email' => 'old.employee@acme.test', 'password' => null, ]); $userAcmeOwnerMember = Member::factory()->forUser($userAcmeOwner)->forOrganization($organizationAcme)->role(Role::Owner)->create(); $userAcmeManagerMember = Member::factory()->forUser($userAcmeManager)->forOrganization($organizationAcme)->role(Role::Manager)->create(); $userAcmeAdminMember = Member::factory()->forUser($userAcmeAdmin)->forOrganization($organizationAcme)->role(Role::Admin)->create(); $userAcmeEmployeeMember = Member::factory()->forUser($userAcmeEmployee)->forOrganization($organizationAcme)->role(Role::Employee)->create(); $userAcmePlaceholderMember = Member::factory()->forUser($userAcmePlaceholder)->forOrganization($organizationAcme)->role(Role::Placeholder)->create(); $userWithMultipleOrganizationsAcmeMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationAcme)->role(Role::Employee)->create(); Tag::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Code Review', ]); Tag::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Meeting', ]); Tag::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Research', ]); TimeEntry::factory() ->count(10) ->forMember($userAcmeAdminMember) ->create(); TimeEntry::factory() ->count(10) ->forMember($userAcmeManagerMember) ->create(); TimeEntry::factory() ->count(10) ->forMember($userAcmePlaceholderMember) ->create(); TimeEntry::factory() ->count(10) ->forMember($userAcmeEmployeeMember) ->create(); TimeEntry::factory() ->count(5) ->forMember($userWithMultipleOrganizationsAcmeMember) ->create(); $acmeClient = Client::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Big Company', ]); $bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($acmeClient)->create([ 'name' => 'Big Company Project', ]); ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeEmployeeMember)->create(); ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeAdminMember)->create(); ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userWithMultipleOrganizationsAcmeMember)->create(); TimeEntry::factory() ->count(3) ->forMember($userAcmeEmployeeMember) ->forProject($bigCompanyProject) ->create(); Task::factory()->forOrganization($organizationAcme)->forProject($bigCompanyProject)->create(); $internalProject = Project::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Internal Project', ]); $rivalOwner = User::factory()->create([ 'name' => 'Other Owner', 'email' => 'owner@rival-company.test', ]); $organizationRival = Organization::factory()->withOwner($rivalOwner)->create([ 'name' => 'Rival Corp', 'personal_team' => true, 'currency' => 'USD', ]); Member::factory()->forUser($rivalOwner)->forOrganization($organizationRival)->role(Role::Owner)->create(); $userRivalManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Other User', 'email' => 'test@rival-company.test', ]); $userRivalManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationRival)->role(Role::Admin)->create(); $userWithMultipleOrganizationsRivalMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationRival)->role(Role::Employee)->create(); $rivalClient = Client::factory()->forOrganization($organizationRival)->create([ 'name' => 'Scale Company', ]); $otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($rivalClient)->create([ 'name' => 'Scale Company - Project ABC', ]); ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userRivalManagerMember)->create(); ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userWithMultipleOrganizationsRivalMember)->create(); TimeEntry::factory() ->count(5) ->forMember($userWithMultipleOrganizationsRivalMember) ->create(); User::factory()->withPersonalOrganization()->create([ 'email' => 'admin@example.com', ]); DatabaseSeederAfterSeed::dispatch(); } private function deleteAll(): void { DatabaseSeederBeforeDelete::dispatch(); // Laravel Passport tables DB::table((new RefreshToken)->getTable())->delete(); DB::table((new Token)->getTable())->delete(); DB::table((new AuthCode)->getTable())->delete(); DB::table((new PassportClient)->getTable())->delete(); // Internal tables DB::table('cache')->delete(); DB::table('cache_locks')->delete(); DB::table('jobs')->delete(); DB::table('failed_jobs')->delete(); DB::table('sessions')->delete(); // Application tables DB::table((new Audit)->getTable())->delete(); DB::table((new Report)->getTable())->delete(); DB::table((new TimeEntry)->getTable())->delete(); DB::table((new Task)->getTable())->delete(); DB::table((new Tag)->getTable())->delete(); DB::table((new ProjectMember)->getTable())->delete(); DB::table((new Project)->getTable())->delete(); DB::table((new Client)->getTable())->delete(); DB::table((new Member)->getTable())->delete(); DB::table((new OrganizationInvitation)->getTable())->delete(); DB::table((new User)->getTable())->update([ 'current_team_id' => null, ]); DB::table((new Organization)->getTable())->delete(); DB::table((new User)->getTable())->delete(); } } ================================================ FILE: docker/local/8.3/Dockerfile ================================================ 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"] ================================================ FILE: docker/local/8.3/php.ini ================================================ [PHP] post_max_size = 100M upload_max_filesize = 100M variables_order = EGPCS pcov.directory = . [opcache] opcache.enable_cli=1 ================================================ FILE: docker/local/8.3/start-container ================================================ #!/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 ================================================ FILE: docker/local/8.3/supervisord.conf ================================================ [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 ================================================ FILE: docker/local/minio/create_bucket.sh ================================================ #!/bin/sh # 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 rm -r --force local/${S3_BUCKET}; /usr/bin/mc mb --ignore-existing local/${S3_BUCKET}; /usr/bin/mc anonymous set public local/${S3_BUCKET}; exit 0; ================================================ FILE: docker/local/pgsql/create-testing-database.sql ================================================ SELECT 'CREATE DATABASE testing' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'testing')\gexec ================================================ FILE: docker/prod/Dockerfile ================================================ ARG PHP_VERSION=8.3 ARG FRANKENPHP_VERSION=1.8 ARG COMPOSER_VERSION=2.8 ARG BUN_VERSION="latest" ARG APP_ENV ARG DOCKER_FILES_BASE_PATH="docker/prod/" FROM composer:${COMPOSER_VERSION} AS vendor FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-builder-php${PHP_VERSION} AS upstream COPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy RUN CGO_ENABLED=1 \ XCADDY_SETCAP=1 \ XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \ CGO_CFLAGS=$(php-config --includes) \ CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \ xcaddy build 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 " LABEL org.opencontainers.image.title="solidtime" LABEL org.opencontainers.image.description="solidtime is a modern open source timetracker for freelancers and agencies" LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime" LABEL org.opencontainers.image.licenses="AGPL" ARG WWWUSER=1000 ARG WWWGROUP=1000 ARG TZ=UTC ARG APP_DIR=/var/www/html ARG APP_ENV ARG APP_HOST ARG DOCKER_FILES_BASE_PATH ENV DEBIAN_FRONTEND=noninteractive \ TERM=xterm-color \ OCTANE_SERVER=frankenphp \ TZ=${TZ} \ USER=octane \ ROOT=${APP_DIR} \ APP_ENV=${APP_ENV} \ COMPOSER_FUND=0 \ COMPOSER_MAX_PARALLEL_HTTP=24 \ XDG_CONFIG_HOME=${APP_DIR}/.config \ XDG_DATA_HOME=${APP_DIR}/.data \ SERVER_NAME=${APP_HOST} WORKDIR ${ROOT} SHELL ["/bin/bash", "-eou", "pipefail", "-c"] RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \ && echo ${TZ} > /etc/timezone RUN apt-get update; \ apt-get upgrade -yqq; \ apt-get install -yqq --no-install-recommends --show-progress \ apt-utils \ curl \ wget \ vim \ git \ ncdu \ procps \ unzip \ ca-certificates \ supervisor \ libsodium-dev \ libbrotli-dev \ # Install PHP extensions (included with dunglas/frankenphp) && install-php-extensions \ bz2 \ pcntl \ mbstring \ bcmath \ sockets \ pgsql \ pdo_pgsql \ opcache \ exif \ pdo_mysql \ zip \ uv \ vips \ intl \ gd \ redis \ rdkafka \ memcached \ igbinary \ ldap \ && apt-get -y autoremove \ && apt-get clean \ && docker-php-source delete \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ && rm /var/log/lastlog /var/log/faillog RUN arch="$(uname -m)" \ && case "$arch" in \ armhf) _cronic_fname='supercronic-linux-arm' ;; \ aarch64) _cronic_fname='supercronic-linux-arm64' ;; \ x86_64) _cronic_fname='supercronic-linux-amd64' ;; \ x86) _cronic_fname='supercronic-linux-386' ;; \ *) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \ esac \ && wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \ -O /usr/bin/supercronic \ && chmod +x /usr/bin/supercronic \ && mkdir -p /etc/supercronic \ && echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel RUN userdel --remove --force www-data \ && groupadd --force -g ${WWWGROUP} ${USER} \ && useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \ && setcap -r /usr/local/bin/frankenphp RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \ && chmod -R a+rw ${ROOT} /var/{log,run} RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini USER ${USER} COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/ COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/ COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/ COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck ########################################### #FROM base AS common # #USER ${USER} # #COPY --link --chown=${WWWUSER}:${WWWUSER} . . # #RUN composer install \ # --no-dev \ # --no-interaction \ # --no-autoloader \ # --no-ansi \ # --no-scripts \ # --audit ########################################### # Build frontend assets with Bun ########################################### #FROM oven/bun:${BUN_VERSION} AS build # #ARG APP_ENV # #ENV ROOT=/var/www/html \ # APP_ENV=${APP_ENV} \ # NODE_ENV=${APP_ENV:-production} # #WORKDIR ${ROOT} # #COPY --link package.json bun.lock* ./ # #RUN bun install --frozen-lockfile # #COPY --link . . #COPY --link --from=common ${ROOT}/vendor vendor # #RUN bun run build ########################################### #FROM common AS runner USER ${USER} ENV WITH_HORIZON=false \ WITH_SCHEDULER=false \ WITH_REVERB=false COPY --link --chown=${WWWUSER}:${WWWUSER} . . #COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public RUN mkdir -p \ storage/framework/{sessions,views,cache,testing} \ storage/logs \ bootstrap/cache && chmod -R a+rw storage #RUN composer install \ # --classmap-authoritative \ # --no-interaction \ # --no-ansi \ # --no-dev \ # && composer clear-cache RUN cat .env #RUN php artisan env EXPOSE 8000 ENTRYPOINT ["start-container"] #HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1 ================================================ FILE: docker/prod/LICENSE ================================================ MIT License Copyright (c) 2021 Exa Company Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: docker/prod/deployment/healthcheck ================================================ #!/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 ================================================ FILE: docker/prod/deployment/octane/FrankenPHP/Caddyfile ================================================ { {$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 } } } ================================================ FILE: docker/prod/deployment/octane/FrankenPHP/supervisord.frankenphp.conf ================================================ [program:octane] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile user = %(ENV_USER)s priority = 1 autostart = true autorestart = true environment = LARAVEL_OCTANE = "1" stdout_logfile = /dev/stdout stdout_logfile_maxbytes = 0 stderr_logfile = /dev/stderr stderr_logfile_maxbytes = 0 [program:horizon] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan horizon user = %(ENV_USER)s priority = 3 autostart = %(ENV_WITH_HORIZON)s autorestart = true stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log stdout_logfile_maxbytes = 200MB stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log stderr_logfile_maxbytes = 200MB stopwaitsecs = 3600 [program:scheduler] process_name = %(program_name)s_%(process_num)s command = supercronic -overlapping /etc/supercronic/laravel user = %(ENV_USER)s autostart = %(ENV_WITH_SCHEDULER)s autorestart = true stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log stdout_logfile_maxbytes = 200MB stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log stderr_logfile_maxbytes = 200MB [program:clear-scheduler-cache] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan schedule:clear-cache user = %(ENV_USER)s autostart = %(ENV_WITH_SCHEDULER)s autorestart = false startsecs = 0 startretries = 1 stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log stdout_logfile_maxbytes = 200MB stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log stderr_logfile_maxbytes = 200MB [program:reverb] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan reverb:start user = %(ENV_USER)s priority = 2 autostart = %(ENV_WITH_REVERB)s autorestart = true stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log stdout_logfile_maxbytes = 200MB stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log stderr_logfile_maxbytes = 200MB minfds = 10000 [include] files = /etc/supervisord.conf ================================================ FILE: docker/prod/deployment/php.ini ================================================ [PHP] post_max_size = 100M upload_max_filesize = 100M expose_php = 0 realpath_cache_size = 16M realpath_cache_ttl = 360 max_input_time = 5 register_argc_argv = 0 date.timezone = ${TZ:-UTC} [Opcache] opcache.enable = 1 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 [JIT] opcache.jit_buffer_size = 128M opcache.jit = function 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 ================================================ FILE: docker/prod/deployment/start-container ================================================ #!/usr/bin/env sh set -e container_mode=${CONTAINER_MODE:-"http"} octane_server=${OCTANE_SERVER} auto_db_migrate=${AUTO_DB_MIGRATE:-false} initialStuff() { echo "Container mode: $container_mode" if [ ${auto_db_migrate} = "true" ]; then echo "Auto database migration enabled." php artisan migrate --isolated --force fi php artisan storage:link; \ php artisan optimize:clear; \ php artisan optimize; } if [ "$1" != "" ]; then exec "$@" elif [ "${container_mode}" = "http" ]; then initialStuff echo "Octane Server: $octane_server" if [ "${octane_server}" = "frankenphp" ]; then exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf elif [ "${octane_server}" = "swoole" ]; then exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf elif [ "${octane_server}" = "roadrunner" ]; then exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf else echo "Invalid Octane server supplied." exit 1 fi elif [ "${container_mode}" = "horizon" ]; then initialStuff exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.horizon.conf elif [ "${container_mode}" = "reverb" ]; then initialStuff exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.reverb.conf elif [ "${container_mode}" = "scheduler" ]; then initialStuff exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.scheduler.conf elif [ "${container_mode}" = "worker" ]; then if [ -z "${WORKER_COMMAND}" ]; then echo "WORKER_COMMAND is undefined." exit 1 fi initialStuff exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.worker.conf else echo "Container mode mismatched." exit 1 fi ================================================ FILE: docker/prod/deployment/supervisord.conf ================================================ [supervisord] nodaemon = true user = %(ENV_USER)s logfile = /var/log/supervisor/supervisord.log pidfile = /var/run/supervisord.pid [supervisorctl] [inet_http_server] port = 127.0.0.1:9001 [rpcinterface:supervisor] supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface ================================================ FILE: docker/prod/deployment/supervisord.horizon.conf ================================================ [program:horizon] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan horizon user = %(ENV_USER)s autostart = true autorestart = true stdout_logfile = /dev/stdout stdout_logfile_maxbytes = 0 stderr_logfile = /dev/stderr stderr_logfile_maxbytes = 0 stopwaitsecs = 3600 [include] files = /etc/supervisord.conf ================================================ FILE: docker/prod/deployment/supervisord.reverb.conf ================================================ [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 ================================================ FILE: docker/prod/deployment/supervisord.scheduler.conf ================================================ [program:scheduler] process_name = %(program_name)s_%(process_num)s command = supercronic -overlapping /etc/supercronic/laravel user = %(ENV_USER)s autostart = true autorestart = true stdout_logfile = /dev/stdout stdout_logfile_maxbytes = 0 stderr_logfile = /dev/stderr stderr_logfile_maxbytes = 0 [program:clear-scheduler-cache] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan schedule:clear-cache user = %(ENV_USER)s autostart = true autorestart = false startsecs = 0 startretries = 1 stdout_logfile = /dev/stdout stdout_logfile_maxbytes = 0 stderr_logfile = /dev/stderr stderr_logfile_maxbytes = 0 [include] files = /etc/supervisord.conf ================================================ FILE: docker/prod/deployment/supervisord.worker.conf ================================================ [program:worker] process_name = %(program_name)s_%(process_num)s command = %(ENV_WORKER_COMMAND)s user = %(ENV_USER)s autostart = true autorestart = true stdout_logfile = /dev/stdout stdout_logfile_maxbytes = 0 stderr_logfile = /dev/stderr stderr_logfile_maxbytes = 0 [include] files = /etc/supervisord.conf ================================================ FILE: docker-compose.yml ================================================ services: laravel.test: build: context: ./docker/local/8.3 dockerfile: Dockerfile args: WWWGROUP: '${WWWGROUP}' image: sail-8.3/app labels: - "traefik.enable=true" - "traefik.docker.network=${NETWORK_NAME}" - "traefik.http.services.solidtime-dev.loadbalancer.server.port=80" - "traefik.http.routers.solidtime-dev.rule=Host(`${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev.entrypoints=web" - "traefik.http.routers.solidtime-dev.service=solidtime-dev" - "traefik.http.routers.solidtime-dev-https.rule=Host(`${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev-https.service=solidtime-dev" - "traefik.http.routers.solidtime-dev-https.entrypoints=websecure" - "traefik.http.routers.solidtime-dev-https.tls=true" # vite - "traefik.http.services.solidtime-dev-vite.loadbalancer.server.port=5173" # http - "traefik.http.routers.solidtime-dev-vite.rule=Host(`${VITE_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev-vite.service=solidtime-dev-vite" - "traefik.http.routers.solidtime-dev-vite.entrypoints=web" extra_hosts: - "host.docker.internal:host-gateway" - "storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}" environment: XDG_CONFIG_HOME: /var/www/html/config XDG_DATA_HOME: /var/www/html/data WWWUSER: '${WWWUSER}' LARAVEL_SAIL: 1 XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' IGNITION_LOCAL_SITES_PATH: '${PWD}' VITE_HOST_NAME: '${VITE_HOST_NAME}' volumes: - '.:/var/www/html' networks: - sail - reverse-proxy depends_on: - pgsql pgsql: image: 'postgres:15' ports: - '${FORWARD_DB_PORT:-5432}:5432' environment: PGPASSWORD: '${DB_PASSWORD:-secret}' POSTGRES_DB: '${DB_DATABASE}' POSTGRES_USER: '${DB_USERNAME}' POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' volumes: - 'sail-pgsql:/var/lib/postgresql/data' - './docker/local/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' networks: - sail healthcheck: test: - CMD - pg_isready - '-q' - '-d' - '${DB_DATABASE}' - '-U' - '${DB_USERNAME}' retries: 3 timeout: 5s pgsql_test: image: 'postgres:15' environment: PGPASSWORD: '${DB_PASSWORD:-secret}' POSTGRES_DB: '${DB_DATABASE}' POSTGRES_USER: '${DB_USERNAME}' POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' volumes: - 'sail-pgsql-test:/var/lib/postgresql/data' - './docker/local/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' networks: - sail healthcheck: test: - CMD - pg_isready - '-q' - '-d' - '${DB_DATABASE}' - '-U' - '${DB_USERNAME}' retries: 3 timeout: 5s mailpit: image: 'axllent/mailpit:latest' labels: - "traefik.enable=true" - "traefik.docker.network=${NETWORK_NAME}" - "traefik.http.services.solidtime-dev-mailpit.loadbalancer.server.port=8025" - "traefik.http.routers.solidtime-dev-mailpit.rule=Host(`mail.${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev-mailpit.entrypoints=web" - "traefik.http.routers.solidtime-dev-mailpit.service=solidtime-dev-mailpit" - "traefik.http.routers.solidtime-dev-mailpit-https.rule=Host(`mail.${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev-mailpit-https.service=solidtime-dev-mailpit" - "traefik.http.routers.solidtime-dev-mailpit-https.entrypoints=websecure" - "traefik.http.routers.solidtime-dev-mailpit-https.tls=true" networks: - sail - reverse-proxy playwright: image: mcr.microsoft.com/playwright:v1.58.1-jammy command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] working_dir: /src extra_hosts: - "${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}" - "${VITE_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}" labels: - "traefik.enable=true" - "traefik.docker.network=${NETWORK_NAME}" - "traefik.http.services.solidtime-dev-playwright.loadbalancer.server.port=8080" - "traefik.http.routers.solidtime-dev-playwright.rule=Host(`playwright.${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev-playwright.entrypoints=web" - "traefik.http.routers.solidtime-dev-playwright-https.rule=Host(`playwright.${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev-playwright-https.entrypoints=websecure" - "traefik.http.routers.solidtime-dev-playwright-https.tls=true" networks: - sail - reverse-proxy volumes: - '.:/src' minio: image: 'minio/minio:latest' environment: MINIO_BROWSER_REDIRECT_URL: 'https://storage-management.${NGINX_HOST_NAME}' MINIO_ROOT_USER: 'sail' MINIO_ROOT_PASSWORD: 'password' volumes: - 'sail-minio:/data/minio' networks: - reverse-proxy - sail command: minio server /data/minio --console-address ":8900" healthcheck: test: [ "CMD", "mc", "ready", "local" ] interval: 5s timeout: 5s retries: 5 labels: - "traefik.enable=true" - "traefik.docker.network=${NETWORK_NAME}" # Storage Frontend - "traefik.http.services.solidtime-dev-storage-frontend.loadbalancer.server.port=9000" # http - "traefik.http.routers.solidtime-dev-storage-frontend.rule=Host(`storage.${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev-storage-frontend.service=solidtime-dev-storage-frontend" - "traefik.http.routers.solidtime-dev-storage-frontend.entrypoints=web" # https - "traefik.http.routers.solidtime-dev-storage-frontend-https.rule=Host(`storage.${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev-storage-frontend-https.service=solidtime-dev-storage-frontend" - "traefik.http.routers.solidtime-dev-storage-frontend-https.entrypoints=websecure" - "traefik.http.routers.solidtime-dev-storage-frontend-https.tls=true" # Storage Management - "traefik.http.services.solidtime-dev-storage-management.loadbalancer.server.port=8900" # http - "traefik.http.routers.solidtime-dev-storage-management.rule=Host(`storage-management.${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev-storage-management.service=solidtime-dev-storage-management" - "traefik.http.routers.solidtime-dev-storage-management.entrypoints=web" # https - "traefik.http.routers.solidtime-dev-storage-management-https.rule=Host(`storage-management.${NGINX_HOST_NAME}`)" - "traefik.http.routers.solidtime-dev-storage-management-https.service=solidtime-dev-storage-management" - "traefik.http.routers.solidtime-dev-storage-management-https.entrypoints=websecure" - "traefik.http.routers.solidtime-dev-storage-management-https.tls=true" minio-create-bucket: image: minio/mc:latest depends_on: - minio environment: S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY} S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} volumes: - './docker/local/minio:/etc/minio' networks: - sail - reverse-proxy entrypoint: /etc/minio/create_bucket.sh extra_hosts: - "storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}" gotenberg: image: gotenberg/gotenberg:8 networks: - sail healthcheck: test: ["CMD", "curl", "--silent", "--fail", "http://localhost:3000/health"] networks: reverse-proxy: name: "${NETWORK_NAME}" external: true sail: driver: bridge volumes: sail-pgsql: driver: local sail-pgsql-test: driver: local sail-minio: driver: local ================================================ FILE: e2e/auth.spec.ts ================================================ import { expect, test } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { getPasswordResetUrl } from './utils/mailpit'; async function registerNewUser(page, email, password) { await page.goto(PLAYWRIGHT_BASE_URL + '/register'); await page.getByLabel('Name').fill('John Doe'); await page.getByLabel('Email').fill(email); await page.getByLabel('Password', { exact: true }).fill(password); await page.getByLabel('Confirm Password').fill(password); await page.getByLabel('I agree to the Terms of').click(); await page.getByRole('button', { name: 'Register' }).click(); await expect(page.getByTestId('dashboard_view')).toBeVisible(); } test('can register, logout and log back in', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL); const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; const password = 'suchagreatpassword123'; await registerNewUser(page, email, password); await expect(page.getByTestId('dashboard_view')).toBeVisible(); await page.getByTestId('current_user_button').click(); await page.getByText('Log Out').click(); await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); await page.goto(PLAYWRIGHT_BASE_URL + '/login'); await page.getByLabel('Email').fill(email); await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Log in' }).click(); await expect(page.getByTestId('dashboard_view')).toBeVisible(); }); test('can register and delete account', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL); const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; const password = 'suchagreatpassword123'; await registerNewUser(page, email, password); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.getByRole('button', { name: 'Delete Account' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.getByPlaceholder('Password').fill(password); await page.getByRole('dialog').getByRole('button', { name: 'Delete Account' }).click(); await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); await page.goto(PLAYWRIGHT_BASE_URL + '/login'); await page.getByLabel('Email').fill(email); await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Log in' }).click(); await expect(page.getByRole('alert')).toContainText( 'These credentials do not match our records.' ); }); test('shows error for invalid email on forgot password', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); // Request password reset with non-existent email await page.getByLabel('Email').fill('nonexistent@example.com'); await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); // Should show error message await expect(page.getByText("We can't find a user with that email address.")).toBeVisible(); }); test('shows browser validation for invalid email format on forgot password', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); // Request password reset with invalid email format const emailInput = page.getByLabel('Email'); await emailInput.fill('notanemail'); // Check for browser validation - the input should be invalid const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid); expect(isInvalid).toBe(true); }); test('shows browser validation for empty email on forgot password', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); // The email input is required, so it should be invalid when empty const emailInput = page.getByLabel('Email'); // Check for browser validation - the input should be invalid because it's required and empty const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valueMissing); expect(isInvalid).toBe(true); }); test('can reset password via email link', async ({ page, request }) => { // First register a new user const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; const originalPassword = 'suchagreatpassword123'; const newPassword = 'mynewsecurepassword456'; await registerNewUser(page, email, originalPassword); // Log out await page.getByTestId('current_user_button').click(); await page.getByText('Log Out').click(); await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); // Request password reset await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); await page.getByLabel('Email').fill(email); await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); await expect(page.getByText('We have emailed your password reset link.')).toBeVisible(); // Get password reset URL from email const resetUrl = await getPasswordResetUrl(request, email); // Navigate to reset page await page.goto(resetUrl); // Fill in new password await page.getByLabel('Password', { exact: true }).fill(newPassword); await page.getByLabel('Confirm Password').fill(newPassword); await page.getByRole('button', { name: 'Reset Password' }).click(); // Should redirect to login page after successful reset await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); // Try logging in with new password await page.getByLabel('Email').fill(email); await page.getByLabel('Password').fill(newPassword); await page.getByRole('button', { name: 'Log in' }).click(); await expect(page.getByTestId('dashboard_view')).toBeVisible(); }); test('shows validation error for password mismatch on reset', async ({ page, request }) => { // First register a new user const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; const originalPassword = 'suchagreatpassword123'; await registerNewUser(page, email, originalPassword); // Log out await page.getByTestId('current_user_button').click(); await page.getByText('Log Out').click(); await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); // Request password reset await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); await page.getByLabel('Email').fill(email); await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); await expect(page.getByText('We have emailed your password reset link.')).toBeVisible(); // Get password reset URL from email const resetUrl = await getPasswordResetUrl(request, email); // Navigate to reset page await page.goto(resetUrl); // Fill in mismatched passwords await page.getByLabel('Password', { exact: true }).fill('newpassword123'); await page.getByLabel('Confirm Password').fill('differentpassword456'); await page.getByRole('button', { name: 'Reset Password' }).click(); // Should show validation error await expect(page.getByText('The password field confirmation does not match.')).toBeVisible(); }); test('shows validation error for short password on reset', async ({ page, request }) => { // First register a new user const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; const originalPassword = 'suchagreatpassword123'; await registerNewUser(page, email, originalPassword); // Log out await page.getByTestId('current_user_button').click(); await page.getByText('Log Out').click(); await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); // Request password reset await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); await page.getByLabel('Email').fill(email); await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); await expect(page.getByText('We have emailed your password reset link.')).toBeVisible(); // Get password reset URL from email const resetUrl = await getPasswordResetUrl(request, email); // Navigate to reset page await page.goto(resetUrl); // Fill in short password await page.getByLabel('Password', { exact: true }).fill('short'); await page.getByLabel('Confirm Password').fill('short'); await page.getByRole('button', { name: 'Reset Password' }).click(); // Should show validation error about minimum length await expect(page.getByText('must be at least')).toBeVisible(); }); test('shows error for invalid login credentials', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/login'); await page.getByLabel('Email').fill('nonexistent@example.com'); await page.getByLabel('Password').fill('wrongpassword123'); await page.getByRole('button', { name: 'Log in' }).click(); await expect(page.getByText('These credentials do not match our records.')).toBeVisible(); }); test('shows error when registering with existing email', async ({ page }) => { const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; const password = 'suchagreatpassword123'; // Register first user await registerNewUser(page, email, password); // Log out await page.getByTestId('current_user_button').click(); await page.getByText('Log Out').click(); await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); // Try to register with the same email await page.goto(PLAYWRIGHT_BASE_URL + '/register'); await page.getByLabel('Name').fill('Another User'); await page.getByLabel('Email').fill(email); await page.getByLabel('Password', { exact: true }).fill(password); await page.getByLabel('Confirm Password').fill(password); await page.getByLabel('I agree to the Terms of').click(); await page.getByRole('button', { name: 'Register' }).click(); // Should show error about email already taken await expect(page.getByText('The resource already exists.')).toBeVisible(); }); test('shows validation error for weak password on registration', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/register'); await page.getByLabel('Name').fill('Weak Password User'); await page.getByLabel('Email').fill(`weak+${Math.round(Math.random() * 10000)}@test.com`); await page.getByLabel('Password', { exact: true }).fill('short'); await page.getByLabel('Confirm Password').fill('short'); await page.getByLabel('I agree to the Terms of').click(); await page.getByRole('button', { name: 'Register' }).click(); await expect(page.getByText('must be at least')).toBeVisible(); }); ================================================ FILE: e2e/calendar-settings.spec.ts ================================================ import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; async function goToCalendar(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); await expect(page.locator('.fc')).toBeVisible(); } async function openSettingsPopover(page: Page) { await page.getByRole('button', { name: 'Calendar settings' }).click(); await expect(page.getByText('Calendar Settings')).toBeVisible(); } async function clearCalendarSettings(page: Page) { await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings')); } test.describe('Calendar Settings', () => { test.beforeEach(async ({ page }) => { await clearCalendarSettings(page); }); test('settings popover shows all fields with correct defaults', async ({ page }) => { await goToCalendar(page); await openSettingsPopover(page); await expect(page.getByLabel('Snap Interval')).toContainText('15 min'); await expect(page.getByLabel('Start Time')).toContainText('12:00 AM'); await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)'); await expect(page.getByLabel('Grid Scale')).toContainText('15 min'); }); test('snap interval can be changed and persists across reload', async ({ page }) => { await goToCalendar(page); await openSettingsPopover(page); // Change snap interval to 30 min await page.getByLabel('Snap Interval').click(); await page.getByRole('option', { name: '30 min' }).click(); await page.locator('.fc-toolbar-title').click(); // Verify localStorage was updated const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}') ); expect(stored.snapMinutes).toBe(30); // Reload and verify persistence await page.reload(); await expect(page.locator('.fc')).toBeVisible(); await openSettingsPopover(page); await expect(page.getByLabel('Snap Interval')).toContainText('30 min'); }); test('start time change is applied to calendar and rejects values >= end time', async ({ page, }) => { await goToCalendar(page); // Verify 7 AM slot exists with default start (00:00) await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0); await openSettingsPopover(page); // Set end time to 6 PM first await page.getByLabel('End Time').click(); await page.getByRole('option', { name: '6:00 PM' }).click(); // Change start time to 8 AM (valid) await page.getByLabel('Start Time').click(); await page.getByRole('option', { name: '8:00 AM' }).click(); await page.locator('.fc-toolbar-title').click(); // Calendar should no longer show hours before 8 AM await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0); await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0); // Try to set start time to 6 PM (invalid: equals end time) await openSettingsPopover(page); await page.getByLabel('Start Time').click(); await page.getByRole('option', { name: '6:00 PM' }).click(); // Should be rejected — start time stays at 8 AM await expect(page.getByLabel('Start Time')).toContainText('8:00 AM'); }); test('end time change is applied to calendar and rejects values <= start time', async ({ page, }) => { await goToCalendar(page); // Verify 19:00 slot exists with default end (24:00) await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0); await openSettingsPopover(page); // Set start time to 8 AM first await page.getByLabel('Start Time').click(); await page.getByRole('option', { name: '8:00 AM' }).click(); // Change end time to 6 PM (valid) await page.getByLabel('End Time').click(); await page.getByRole('option', { name: '6:00 PM' }).click(); await page.locator('.fc-toolbar-title').click(); // Calendar should no longer show hours at or after 6 PM await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0); await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0); // Try to set end time to 8 AM (invalid: equals start time) await openSettingsPopover(page); await page.getByLabel('End Time').click(); await page.getByRole('option', { name: '8:00 AM' }).click(); // Should be rejected — end time stays at 6 PM await expect(page.getByLabel('End Time')).toContainText('6:00 PM'); }); test('grid scale affects number of calendar slots', async ({ page }) => { await goToCalendar(page); // Count slots with default 15-min scale const defaultSlotCount = await page.locator('.fc-timegrid-slot').count(); // Change to 30 min scale (should halve the slots) await openSettingsPopover(page); await page.getByLabel('Grid Scale').click(); await page.getByRole('option', { name: '30 min' }).click(); await page.locator('.fc-toolbar-title').click(); const largerSlotCount = await page.locator('.fc-timegrid-slot').count(); expect(largerSlotCount).toBeLessThan(defaultSlotCount); // Change to 5 min scale (should have many more slots) await openSettingsPopover(page); await page.getByLabel('Grid Scale').click(); await page.getByRole('option', { name: '5 min', exact: true }).click(); await page.locator('.fc-toolbar-title').click(); const smallerSlotCount = await page.locator('.fc-timegrid-slot').count(); expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount); }); test('all settings persist across navigation', async ({ page }) => { await goToCalendar(page); await openSettingsPopover(page); // Change every setting await page.getByLabel('Snap Interval').click(); await page.getByRole('option', { name: '5 min', exact: true }).click(); await page.getByLabel('Start Time').click(); await page.getByRole('option', { name: '6:00 AM' }).click(); await page.getByLabel('End Time').click(); await page.getByRole('option', { name: '10:00 PM' }).click(); await page.getByLabel('Grid Scale').click(); await page.getByRole('option', { name: '30 min' }).click(); await page.locator('.fc-toolbar-title').click(); // Navigate away and back await page.goto(PLAYWRIGHT_BASE_URL + '/time'); await goToCalendar(page); // Verify all settings persisted await openSettingsPopover(page); await expect(page.getByLabel('Snap Interval')).toContainText('5 min'); await expect(page.getByLabel('Start Time')).toContainText('6:00 AM'); await expect(page.getByLabel('End Time')).toContainText('10:00 PM'); await expect(page.getByLabel('Grid Scale')).toContainText('30 min'); }); }); ================================================ FILE: e2e/calendar.spec.ts ================================================ import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; import { createBillableProjectViaApi, createProjectViaApi, createBareTimeEntryViaApi, createTimeEntryViaApi, } from './utils/api'; async function goToCalendar(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); } /** * These tests verify that changing the project on a time entry via the calendar * updates the billable status to match the new project's is_billable setting. * * Issue: https://github.com/solidtime-io/solidtime/issues/981 */ test('test that changing project in calendar edit modal from non-billable to billable updates billable status', async ({ page, ctx, }) => { const billableProjectName = 'Billable Cal Project ' + Math.floor(1 + Math.random() * 10000); await createBillableProjectViaApi(ctx, { name: billableProjectName }); await createBareTimeEntryViaApi(ctx, 'Test billable calendar', '1h'); await goToCalendar(page); // Click on the time entry event in the calendar await page.locator('.fc-event').filter({ hasText: 'Test billable calendar' }).first().click(); await expect(page.getByRole('dialog')).toBeVisible(); // Verify initially non-billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) ).toBeVisible(); // Select the billable project await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); await page.getByRole('option', { name: billableProjectName }).click(); // Verify the billable dropdown updated to Billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) ).toBeVisible(); // Save and verify const [updateResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Time Entry' }).click(), ]); const responseBody = await updateResponse.json(); expect(responseBody.data.billable).toBe(true); }); test('test that changing project in calendar edit modal from billable to non-billable updates billable status', async ({ page, ctx, }) => { const billableProjectName = 'Billable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000); const nonBillableProjectName = 'NonBillable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000); await createBillableProjectViaApi(ctx, { name: billableProjectName }); await createProjectViaApi(ctx, { name: nonBillableProjectName }); await createBareTimeEntryViaApi(ctx, 'Test billable cal reverse', '1h'); await goToCalendar(page); // Click on the time entry event in the calendar await page .locator('.fc-event') .filter({ hasText: 'Test billable cal reverse' }) .first() .click(); await expect(page.getByRole('dialog')).toBeVisible(); // First assign the billable project await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); await page.getByRole('option', { name: billableProjectName }).click(); // Verify billable status flipped to Billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) ).toBeVisible(); // Now switch to the non-billable project await page.getByRole('dialog').getByRole('button', { name: billableProjectName }).click(); await page.getByRole('option', { name: nonBillableProjectName }).click(); // Verify billable status reverted to Non-Billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) ).toBeVisible(); // Save and verify const [updateResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Time Entry' }).click(), ]); const responseBody = await updateResponse.json(); expect(responseBody.data.billable).toBe(false); }); test('test that opening calendar edit modal for a time entry with manually overridden billable status preserves that status', async ({ page, ctx, }) => { const billableProjectName = 'Billable Cal Persist Project ' + Math.floor(1 + Math.random() * 10000); await createBillableProjectViaApi(ctx, { name: billableProjectName }); await createBareTimeEntryViaApi(ctx, 'Test cal persist override', '1h'); await goToCalendar(page); // Click on the time entry event in the calendar await page .locator('.fc-event') .filter({ hasText: 'Test cal persist override' }) .first() .click(); await expect(page.getByRole('dialog')).toBeVisible(); // Assign the billable project await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); await page.getByRole('option', { name: billableProjectName }).click(); // Verify it auto-set to Billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) ).toBeVisible(); // Now manually override billable to Non-Billable via the dropdown await page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }).click(); await page.getByRole('option', { name: 'Non Billable' }).click(); // Verify it shows Non-Billable now await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) ).toBeVisible(); // Save const [firstSaveResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Time Entry' }).click(), ]); const firstBody = await firstSaveResponse.json(); expect(firstBody.data.billable).toBe(false); // Re-open the edit modal from the calendar — the project_id watcher should NOT override billable await page .locator('.fc-event') .filter({ hasText: 'Test cal persist override' }) .first() .click(); await expect(page.getByRole('dialog')).toBeVisible(); // The billable dropdown should still show Non-Billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) ).toBeVisible(); // Save without changes and verify the response still has billable=false const [updateResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Time Entry' }).click(), ]); const responseBody = await updateResponse.json(); expect(responseBody.data.billable).toBe(false); }); test('test that calendar page loads and displays time entries', async ({ page, ctx }) => { await createBareTimeEntryViaApi(ctx, 'Calendar display test', '1h'); await goToCalendar(page); // Calendar container should be visible await expect(page.locator('.fc')).toBeVisible(); // The time entry should appear as a calendar event await expect( page.locator('.fc-event').filter({ hasText: 'Calendar display test' }).first() ).toBeVisible(); }); test('test that calendar navigation buttons work', async ({ page }) => { await goToCalendar(page); await expect(page.locator('.fc')).toBeVisible(); // Click the "next" button to navigate forward await page.locator('button.fc-next-button').click(); await expect(page.locator('.fc')).toBeVisible(); // Click the "prev" button to navigate back await page.locator('button.fc-prev-button').click(); await expect(page.locator('.fc')).toBeVisible(); // Navigate forward first so "today" button becomes enabled, then click it await page.locator('button.fc-next-button').click(); await page.locator('button.fc-today-button').click(); await expect(page.locator('.fc')).toBeVisible(); }); test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => { const originalDescription = 'Edit me in calendar ' + Math.floor(1 + Math.random() * 10000); const updatedDescription = 'Updated in calendar ' + Math.floor(1 + Math.random() * 10000); await createBareTimeEntryViaApi(ctx, originalDescription, '1h'); await goToCalendar(page); // Click on the time entry event await page.locator('.fc-event').filter({ hasText: originalDescription }).first().click(); await expect(page.getByRole('dialog')).toBeVisible(); // Update the description (edit modal uses placeholder, not data-testid) const descriptionInput = page.getByRole('dialog').getByPlaceholder('What did you work on?'); await descriptionInput.fill(updatedDescription); // Save and verify const [editResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Time Entry' }).click(), ]); const editBody = await editResponse.json(); expect(editBody.data.description).toBe(updatedDescription); // Verify the updated description is shown in the calendar UI await expect( page.locator('.fc-event').filter({ hasText: updatedDescription }).first() ).toBeVisible(); // Verify the old description is no longer shown await expect( page.locator('.fc-event').filter({ hasText: originalDescription }) ).not.toBeVisible(); }); test('test that deleting time entry from calendar modal works', async ({ page, ctx }) => { const description = 'Delete me from calendar ' + Math.floor(1 + Math.random() * 10000); await createBareTimeEntryViaApi(ctx, description, '1h'); await goToCalendar(page); // Click on the time entry event await page.locator('.fc-event').filter({ hasText: description }).first().click(); await expect(page.getByRole('dialog')).toBeVisible(); // Click the delete button await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'DELETE' && response.status() === 204 ), page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(), ]); // Verify the event is removed from the calendar await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible(); }); // ============================================= // Employee Permission Tests // ============================================= test.describe('Employee Calendar Isolation', () => { test('employee can only see their own time entries on the calendar', async ({ ctx, employee, }) => { // Owner creates a time entry for today const ownerDescription = 'OwnerCalEntry ' + Math.floor(Math.random() * 10000); await createBareTimeEntryViaApi(ctx, ownerDescription, '1h'); // Create a time entry for the employee for today const employeeDescription = 'EmpCalEntry ' + Math.floor(Math.random() * 10000); await createTimeEntryViaApi( { ...ctx, memberId: employee.memberId }, { description: employeeDescription, duration: '30min' } ); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); await expect(employee.page.locator('.fc')).toBeVisible({ timeout: 10000 }); // Employee's event IS visible await expect( employee.page.locator('.fc-event').filter({ hasText: employeeDescription }).first() ).toBeVisible({ timeout: 10000 }); // Owner's event is NOT visible await expect( employee.page.locator('.fc-event').filter({ hasText: ownerDescription }) ).not.toBeVisible(); }); }); ================================================ FILE: e2e/clients.spec.ts ================================================ import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; import { createClientViaApi, createProjectMemberViaApi, createProjectViaApi, createPublicProjectViaApi, } from './utils/api'; import { getTableRowNames } from './utils/table'; async function goToClientsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/clients'); } // Create new client via modal test('test that creating and deleting a new client via the modal works', async ({ page }) => { const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000); await goToClientsOverview(page); await page.getByRole('button', { name: 'Create Client' }).click(); await page.getByPlaceholder('Client Name').fill(newClientName); await Promise.all([ page.getByRole('button', { name: 'Create Client' }).click(), page.waitForResponse( async (response) => response.url().includes('/clients') && response.request().method() === 'POST' && response.status() === 201 && (await response.json()).data.id !== null && (await response.json()).data.name === newClientName ), ]); await expect(page.getByTestId('client_table')).toContainText(newClientName); const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']"); await moreButton.click(); const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']"); await Promise.all([ deleteButton.click(), page.waitForResponse( async (response) => response.url().includes('/clients') && response.request().method() === 'DELETE' && response.status() === 204 ), ]); await expect(page.getByTestId('client_table')).not.toContainText(newClientName); }); test('test that archiving and unarchiving clients works', async ({ page, ctx }) => { const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000); await createClientViaApi(ctx, { name: newClientName }); await goToClientsOverview(page); await expect(page.getByText(newClientName)).toBeVisible(); await page.getByRole('row').first().getByRole('button').click(); await Promise.all([ page.getByRole('menuitem').getByText('Archive').click(), expect(page.getByText(newClientName)).not.toBeVisible(), ]); await Promise.all([ page.getByRole('tab', { name: 'Archived' }).click(), expect(page.getByText(newClientName)).toBeVisible(), ]); await page.getByRole('row').first().getByRole('button').click(); await Promise.all([ page.getByRole('menuitem').getByText('Unarchive').click(), expect(page.getByText(newClientName)).not.toBeVisible(), ]); await Promise.all([ page.getByRole('tab', { name: 'Active' }).click(), expect(page.getByText(newClientName)).toBeVisible(), ]); }); test('test that editing a client name works', async ({ page, ctx }) => { const originalName = 'Original Client ' + Math.floor(1 + Math.random() * 10000); const updatedName = 'Updated Client ' + Math.floor(1 + Math.random() * 10000); await createClientViaApi(ctx, { name: originalName }); await goToClientsOverview(page); await expect(page.getByText(originalName)).toBeVisible(); // Open edit modal via actions menu const moreButton = page.locator("[aria-label='Actions for Client " + originalName + "']"); await moreButton.click(); await page.getByTestId('client_edit').click(); // Update the client name await page.getByPlaceholder('Client Name').fill(updatedName); await Promise.all([ page.getByRole('button', { name: 'Update Client' }).click(), page.waitForResponse( async (response) => response.url().includes('/clients') && response.request().method() === 'PUT' && response.status() === 200 ), ]); // Verify updated name is shown and old name is gone await expect(page.getByTestId('client_table')).toContainText(updatedName); await expect(page.getByTestId('client_table')).not.toContainText(originalName); }); test('test that deleting a client via actions menu works', async ({ page, ctx }) => { const clientName = 'DeleteMe Client ' + Math.floor(1 + Math.random() * 10000); await createClientViaApi(ctx, { name: clientName }); await goToClientsOverview(page); await expect(page.getByTestId('client_table')).toContainText(clientName); const moreButton = page.locator("[aria-label='Actions for Client " + clientName + "']"); await moreButton.click(); const deleteButton = page.locator("[aria-label='Delete Client " + clientName + "']"); await Promise.all([ deleteButton.click(), page.waitForResponse( (response) => response.url().includes('/clients') && response.request().method() === 'DELETE' && response.status() === 204 ), ]); await expect(page.getByTestId('client_table')).not.toContainText(clientName); }); // ============================================= // Sorting Tests // ============================================= async function clearClientTableState(page: Page) { await page.evaluate(() => { localStorage.removeItem('client-table-state'); }); } test('test that sorting clients by name and status works', async ({ page, ctx }) => { await createClientViaApi(ctx, { name: 'AAA SortClient' }); await createClientViaApi(ctx, { name: 'ZZZ SortClient' }); await goToClientsOverview(page); await clearClientTableState(page); await page.reload(); const table = page.getByTestId('client_table'); await expect(table).toBeVisible(); // -- Name sorting (default is name asc) -- let names = await getTableRowNames(table); expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient')); const nameHeader = table.getByText('Name').first(); await nameHeader.click(); // toggle to desc names = await getTableRowNames(table); expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient')); // -- Status sorting -- const statusHeader = table.getByText('Status').first(); await statusHeader.click(); // asc await expect(statusHeader.locator('svg')).toBeVisible(); await statusHeader.click(); // desc await expect(statusHeader.locator('svg')).toBeVisible(); }); test('test that sorting clients by project count works', async ({ page, ctx }) => { const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' }); const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' }); // Create projects for the first client await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id }); await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id }); await goToClientsOverview(page); await clearClientTableState(page); await page.reload(); const table = page.getByTestId('client_table'); await expect(table).toBeVisible(); // Click Projects header - first click should sort desc (most projects first) const projectsHeader = table.getByText('Projects').first(); await projectsHeader.click(); await expect(projectsHeader.locator('svg')).toBeVisible(); let names = await getTableRowNames(table); expect(names.indexOf('ManyProjects Client')).toBeLessThan(names.indexOf('NoProjects Client')); // Second click toggles to asc (least projects first) await projectsHeader.click(); names = await getTableRowNames(table); expect(names.indexOf('NoProjects Client')).toBeLessThan(names.indexOf('ManyProjects Client')); }); test('test that client sort state persists after page reload', async ({ page }) => { await goToClientsOverview(page); await clearClientTableState(page); await page.reload(); const table = page.getByTestId('client_table'); await expect(table).toBeVisible(); const nameHeader = table.getByText('Name').first(); await nameHeader.click(); // toggle to desc await expect(nameHeader.locator('svg')).toBeVisible(); await page.reload(); await expect(page.getByTestId('client_table')).toBeVisible(); await expect( page.getByTestId('client_table').getByText('Name').first().locator('svg') ).toBeVisible(); }); // ============================================= // Employee Permission Tests // ============================================= test.describe('Employee Clients Restrictions', () => { test('employee can view clients but cannot create', async ({ ctx, employee }) => { // Create a client with a public project so the employee can see the client const clientName = 'EmpViewClient ' + Math.floor(Math.random() * 10000); const client = await createClientViaApi(ctx, { name: clientName }); await createPublicProjectViaApi(ctx, { name: 'EmpClientProj', client_id: client.id }); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients'); await expect(employee.page.getByTestId('clients_view')).toBeVisible({ timeout: 10000, }); // Employee can see the client await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); // Employee cannot see Create Client button await expect( employee.page.getByRole('button', { name: 'Create Client' }) ).not.toBeVisible(); }); test('employee cannot see edit/delete/archive actions on clients', async ({ ctx, employee, }) => { const clientName = 'EmpActionsClient ' + Math.floor(Math.random() * 10000); const client = await createClientViaApi(ctx, { name: clientName }); await createPublicProjectViaApi(ctx, { name: 'EmpClientActProj', client_id: client.id }); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients'); await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); // Click the actions dropdown trigger to open the menu const actionsButton = employee.page.locator( `[aria-label='Actions for Client ${clientName}']` ); await actionsButton.click(); // The dropdown menu items (Edit, Archive, Delete) should NOT be visible await expect( employee.page.locator(`[aria-label='Edit Client ${clientName}']`) ).not.toBeVisible(); await expect( employee.page.locator(`[aria-label='Archive Client ${clientName}']`) ).not.toBeVisible(); await expect( employee.page.locator(`[aria-label='Delete Client ${clientName}']`) ).not.toBeVisible(); }); test('employee can see client when they are a member of its private project', async ({ ctx, employee, }) => { const clientName = 'EmpPrivateClient ' + Math.floor(Math.random() * 10000); const client = await createClientViaApi(ctx, { name: clientName }); // Create a private project under this client const project = await createProjectViaApi(ctx, { name: 'PrivateProj', client_id: client.id, is_public: false, }); // Add the employee as a project member await createProjectMemberViaApi(ctx, project.id, { member_id: employee.memberId, }); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients'); await expect(employee.page.getByTestId('clients_view')).toBeVisible({ timeout: 10000, }); // Employee can see the client because they are a member of its private project await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); }); }); ================================================ FILE: e2e/command-palette.spec.ts ================================================ import { expect, test } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import type { Page } from '@playwright/test'; const TIMER_BUTTON_SELECTOR = '[data-testid="dashboard_timer"] [data-testid="timer_button"]'; async function goToDashboard(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); } async function openCommandPalette(page: Page) { await page.getByTestId('command_palette_button').click(); await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); } async function closeCommandPalette(page: Page) { await page.keyboard.press('Escape'); await expect(page.locator('[role="dialog"]')).not.toBeVisible(); } async function searchInCommandPalette(page: Page, query: string) { await page.locator('[role="dialog"] input').fill(query); // Wait for search debounce to settle (command palette uses a debounced search) await page.waitForTimeout(300); } async function selectCommand(page: Page, name: string) { const option = page.getByRole('option', { name, exact: true }); await option.scrollIntoViewIfNeeded(); await option.click(); } async function assertTimerIsRunning(page: Page) { await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass( /bg-red-400\/80/, { timeout: 10000, } ); } async function assertTimerIsStopped(page: Page) { await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass( /bg-accent-300\/70/, { timeout: 10000, } ); } test.describe('Command Palette', () => { test.describe('Opening and Closing', () => { test('opens via search button and closes with Escape', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await expect( page.locator('[role="dialog"] input[placeholder*="command"]') ).toBeVisible(); await closeCommandPalette(page); await expect(page.locator('[role="dialog"]')).not.toBeVisible(); }); test('opens with keyboard shortcut', async ({ page }) => { await goToDashboard(page); // Click on body to ensure page has focus await page.locator('body').click(); // Use ControlOrMeta which resolves to Ctrl on Linux/Windows and Meta on macOS await page.keyboard.press('ControlOrMeta+k'); await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); }); test('clears search on close', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'dashboard'); await closeCommandPalette(page); await openCommandPalette(page); await expect(page.locator('[role="dialog"] input')).toHaveValue(''); }); }); test.describe('Command Display', () => { test('displays navigation and timer commands', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); // Navigation commands await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Go to Time' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible(); // Timer commands await expect(page.getByRole('option', { name: 'Start Timer' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Create Time Entry' })).toBeVisible(); }); test('displays create commands', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Create Client' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Create Tag' })).toBeVisible(); }); }); test.describe('Navigation Commands', () => { // Tests use element visibility assertions for consistency with codebase patterns const navigationTests = [ ['Go to Dashboard', 'dashboard_view', '/time'], ['Go to Time', 'time_view', '/dashboard'], ['Go to Calendar', 'calendar_view', '/dashboard'], ['Go to Projects', 'projects_view', '/dashboard'], ['Go to Clients', 'clients_view', '/dashboard'], ['Go to Members', 'members_view', '/dashboard'], ['Go to Tags', 'tags_view', '/dashboard'], ] as const; for (const [commandName, expectedTestId, startUrl] of navigationTests) { test(`${commandName}`, async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + startUrl); await openCommandPalette(page); await searchInCommandPalette(page, commandName.replace('Go to ', '')); await selectCommand(page, commandName); await expect(page.getByTestId(expectedTestId)).toBeVisible({ timeout: 10000 }); }); } test('Go to Profile', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'Profile'); await selectCommand(page, 'Go to Profile'); // Profile page doesn't have a testId, so check for a unique element await expect(page.getByRole('heading', { name: 'Profile Information' })).toBeVisible({ timeout: 10000, }); }); test('Go to Reporting Overview', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'Reporting Overview'); await selectCommand(page, 'Go to Reporting Overview'); await expect(page.getByTestId('reporting_view')).toBeVisible({ timeout: 10000 }); }); test('Go to Settings', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'Settings'); await selectCommand(page, 'Go to Settings'); // Settings page uses team settings which has an h3 heading await expect( page.getByRole('heading', { name: 'Organization Name', level: 3 }) ).toBeVisible({ timeout: 10000, }); }); }); test.describe('Search and Filtering', () => { test('filters commands when searching', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'dashboard'); await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); await searchInCommandPalette(page, 'calendar'); await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible(); }); test('search is case insensitive', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'DASHBOARD'); await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); }); test('partial word search works', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'proj'); await expect(page.getByRole('option', { name: 'Go to Projects' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible(); }); test('keyboard navigation and selection works', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await expect(page.locator('[role="dialog"]')).not.toBeVisible(); }); }); test.describe('Theme Commands', () => { test('switches to dark theme', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'Dark Theme'); await selectCommand(page, 'Switch to Dark Theme'); await expect(page.locator('html')).toHaveClass(/dark/); }); test('switches to light theme', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'Light Theme'); await selectCommand(page, 'Switch to Light Theme'); await expect(page.locator('html')).toHaveClass(/light/); }); }); test.describe('Timer Commands', () => { test('starts and stops timer', async ({ page }) => { await goToDashboard(page); // Start timer await openCommandPalette(page); await searchInCommandPalette(page, 'Start Timer'); await selectCommand(page, 'Start Timer'); await assertTimerIsRunning(page); // Stop timer await openCommandPalette(page); await searchInCommandPalette(page, 'Stop Timer'); await selectCommand(page, 'Stop Timer'); await assertTimerIsStopped(page); }); test('shows active timer commands when running', async ({ page }) => { await goToDashboard(page); // Start timer await openCommandPalette(page); await searchInCommandPalette(page, 'Start Timer'); await selectCommand(page, 'Start Timer'); await assertTimerIsRunning(page); // Check active timer commands - search for them to ensure visibility await openCommandPalette(page); await searchInCommandPalette(page, 'Set Project'); await expect(page.getByRole('option', { name: 'Set Project' })).toBeVisible(); }); }); test.describe('Create Commands', () => { test('opens create time entry modal', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'Create Time Entry'); await selectCommand(page, 'Create Time Entry'); await expect( page.locator('[role="dialog"]').getByText('Create manual time entry') ).toBeVisible(); }); test('opens create project modal', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'Create Project'); await selectCommand(page, 'Create Project'); await expect( page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Project' }) ).toBeVisible(); }); test('opens create client modal', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'Create Client'); await selectCommand(page, 'Create Client'); await expect( page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Client' }) ).toBeVisible(); }); test('opens create tag modal', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'Create Tag'); await selectCommand(page, 'Create Tag'); await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible(); }); test('opens invite member modal', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); await searchInCommandPalette(page, 'Invite Member'); await selectCommand(page, 'Invite Member'); // Modal has title with "Invite Member" text - use first() to get the title span await expect( page.locator('[role="dialog"]').getByText('Invite Member').first() ).toBeVisible(); }); }); test.describe('Entity Search', () => { test('searches for projects and navigates on selection', async ({ page }) => { const projectName = 'CmdPalette' + Math.floor(Math.random() * 10000); // Create project first await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByPlaceholder('The next big thing').fill(projectName); await page.getByRole('button', { name: 'Create Project' }).click(); // Wait for project to be created and page to update await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 }); // Search from the projects page where the query cache now has the new project await openCommandPalette(page); await searchInCommandPalette(page, projectName); // Wait for entity search to return results const projectOption = page.getByRole('option').filter({ hasText: projectName }); await expect(projectOption).toBeVisible({ timeout: 5000, }); // Select the project from search results await projectOption.click(); }); }); test.describe('Organization Switching', () => { test('shows switch commands only when multiple organizations exist', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); // With only one org, no switch commands should appear await searchInCommandPalette(page, 'Switch to'); // Check that no organization switch commands appear (only theme switch commands) const switchOptions = page.getByRole('option', { name: /^Switch to (?!.*Theme)/ }); await expect(switchOptions).toHaveCount(0); }); test('switches organization via command palette', async ({ page }) => { const newOrgName = 'TestOrg' + Math.floor(Math.random() * 10000); // Create a new organization await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create'); await page.getByLabel('Organization Name').fill(newOrgName); await page.getByRole('button', { name: 'Create' }).click(); // Wait for navigation to new org's dashboard await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); // Use visible switcher (desktop sidebar has one, mobile header has another) const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible'); // Verify we're in the new org by checking the switcher await expect(orgSwitcher).toContainText(newOrgName); // Get the original org name from switcher dropdown await orgSwitcher.click(); await expect(page.getByText('Switch Organizations')).toBeVisible(); // Find the other organization button (has ArrowRightIcon, not CheckCircleIcon) // The button contains an SVG and a div with the org name const otherOrgItem = page.locator('form button').filter({ hasText: /.+/ }).first(); await expect(otherOrgItem).toBeVisible(); const originalOrgName = (await otherOrgItem.innerText()).trim(); await page.keyboard.press('Escape'); // Close dropdown // Now use command palette to switch back to original org await openCommandPalette(page); await searchInCommandPalette(page, 'Switch to'); // Should see the switch command for the original org const switchCommand = page.getByRole('option', { name: new RegExp(`Switch to ${originalOrgName}`), }); await expect(switchCommand).toBeVisible(); await switchCommand.click(); // Wait for organization switch to complete await expect(orgSwitcher).toContainText(originalOrgName, { timeout: 10000, }); }); test('organization switch commands appear in Organization group', async ({ page }) => { const newOrgName = 'GroupTestOrg' + Math.floor(Math.random() * 10000); // Create a new organization to ensure we have multiple await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create'); await page.getByLabel('Organization Name').fill(newOrgName); await page.getByRole('button', { name: 'Create' }).click(); await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); // Open command palette and check for Organization group heading await openCommandPalette(page); // The Organization group should be visible when there are switch commands await expect(page.getByText('Organization', { exact: true })).toBeVisible(); }); }); }); // ============================================= // Employee Permission Tests // ============================================= test.describe('Employee Command Palette Restrictions', () => { test('employee command palette does not show restricted navigation commands', async ({ employee, }) => { await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000, }); // Open command palette await employee.page.getByTestId('command_palette_button').click(); await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); // Available navigation commands await expect(employee.page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); await expect(employee.page.getByRole('option', { name: 'Go to Time' })).toBeVisible(); await expect(employee.page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible(); // Restricted commands should NOT be visible await expect( employee.page.getByRole('option', { name: 'Go to Members' }) ).not.toBeVisible(); await expect( employee.page.getByRole('option', { name: 'Go to Settings' }) ).not.toBeVisible(); }); test('employee command palette does not show create commands for restricted entities', async ({ employee, }) => { await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000, }); // Open command palette await employee.page.getByTestId('command_palette_button').click(); await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); // Search for "Create" to filter await employee.page.locator('[role="dialog"] input').fill('Create'); await employee.page.waitForTimeout(300); // Should NOT see create commands for restricted entities await expect( employee.page.getByRole('option', { name: 'Create Project' }) ).not.toBeVisible(); await expect( employee.page.getByRole('option', { name: 'Create Client' }) ).not.toBeVisible(); await expect(employee.page.getByRole('option', { name: 'Create Tag' })).not.toBeVisible(); await expect( employee.page.getByRole('option', { name: 'Invite Member' }) ).not.toBeVisible(); // Should still see Create Time Entry (employees can create time entries) await expect( employee.page.getByRole('option', { name: 'Create Time Entry' }) ).toBeVisible(); }); }); ================================================ FILE: e2e/dashboard.spec.ts ================================================ import { expect, test } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import type { Page } from '@playwright/test'; import { assertThatTimerHasStarted, assertThatTimerIsStopped, newTimeEntryResponse, startOrStopTimerWithButton, stoppedTimeEntryResponse, } from './utils/currentTimeEntry'; import { createBareTimeEntryViaApi, createPublicProjectViaApi, createTimeEntryViaApi, updateOrganizationSettingViaApi, } from './utils/api'; async function goToDashboard(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); } test('test that dashboard loads with all expected sections', async ({ page }) => { await goToDashboard(page); await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); // Timer section (scoped to dashboard_timer to avoid matching sidebar timer) await expect(page.getByTestId('time_entry_description')).toBeVisible(); await expect( page .getByTestId('dashboard_timer') .getByTestId('timer_button') .and(page.locator(':visible')) ).toBeVisible(); // Dashboard cards await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible(); await expect(page.getByText('Last 7 Days', { exact: true })).toBeVisible(); await expect(page.getByText('Activity Graph', { exact: true })).toBeVisible(); await expect(page.getByText('Team Activity', { exact: true })).toBeVisible(); // Weekly overview section await expect(page.getByText('This Week', { exact: true })).toBeVisible(); }); test('test that dashboard shows time entry data after creating entries', async ({ page, ctx }) => { await createBareTimeEntryViaApi(ctx, 'Dashboard test entry', '1h'); await goToDashboard(page); await expect(page.getByTestId('dashboard_view')).toBeVisible(); // The "Last 7 Days" or "This Week" section should reflect tracked time await expect(page.getByText('This Week', { exact: true })).toBeVisible(); }); test('test that timer on dashboard can start and stop', async ({ page }) => { await goToDashboard(page); await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]); await assertThatTimerHasStarted(page); await page.waitForTimeout(1500); await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]); await assertThatTimerIsStopped(page); }); test('test that weekly overview section displays stat cards', async ({ page, ctx }) => { await createBareTimeEntryViaApi(ctx, 'Stats test entry', '2h'); await goToDashboard(page); // Verify stat card labels are visible await expect(page.getByText('Spent Time')).toBeVisible(); await expect(page.getByText('Billable Time')).toBeVisible(); await expect(page.getByText('Billable Amount')).toBeVisible(); }); test('test that stopping timer refreshes dashboard data', async ({ page }) => { await goToDashboard(page); // Start timer await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]); await assertThatTimerHasStarted(page); await page.waitForTimeout(1500); // Stop timer and verify dashboard queries are refetched await Promise.all([ stoppedTimeEntryResponse(page), page.waitForResponse( (response) => response.url().includes('/charts/') && response.request().method() === 'GET' && response.status() === 200 ), startOrStopTimerWithButton(page), ]); await assertThatTimerIsStopped(page); }); // ============================================= // Employee Permission Tests // ============================================= test.describe('Employee Dashboard Restrictions', () => { test('employee dashboard loads and timer is functional', async ({ employee }) => { await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000, }); // Timer should be available await expect( employee.page .getByTestId('dashboard_timer') .getByTestId('timer_button') .and(employee.page.locator(':visible')) ).toBeVisible(); await expect(employee.page.getByTestId('time_entry_description')).toBeEditable(); }); test('employee cannot see Team Activity card', async ({ employee }) => { await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000, }); // Other dashboard cards should be visible await expect(employee.page.getByText('Recent Time Entries', { exact: true })).toBeVisible(); // Team Activity should NOT be visible for employees await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible(); }); test('employee cannot see Cost column in This Week table by default', async ({ ctx, employee, }) => { const project = await createPublicProjectViaApi(ctx, { name: 'EmpDashBillProj', is_billable: true, billable_rate: 10000, }); await createTimeEntryViaApi( { ...ctx, memberId: employee.memberId }, { description: 'Emp dashboard cost entry', duration: '1h', projectId: project.id, billable: true, } ); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000, }); // This Week table should be visible await expect(employee.page.getByText('This Week', { exact: true })).toBeVisible(); // Duration column should be visible, but Cost column should NOT await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible(); await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible(); }); test('employee can see Cost column in This Week table when employees_can_see_billable_rates is enabled', async ({ ctx, employee, }) => { await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true }); const project = await createPublicProjectViaApi(ctx, { name: 'EmpDashBillVisProj', is_billable: true, billable_rate: 10000, }); await createTimeEntryViaApi( { ...ctx, memberId: employee.memberId }, { description: 'Emp dashboard cost visible entry', duration: '1h', projectId: project.id, billable: true, } ); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000, }); // Both Duration and Cost columns should be visible await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible(); await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible(); // 1h at 100.00/h = 100.00 EUR cost should be visible await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible(); }); }); ================================================ FILE: e2e/import-export.spec.ts ================================================ import { expect, test } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import type { Page } from '@playwright/test'; import path from 'path'; async function goToImportExport(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/import'); } test('test that import page loads with type dropdown and file upload', async ({ page }) => { await goToImportExport(page); await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 }); // Import section await expect(page.getByRole('heading', { name: 'Import Data' })).toBeVisible(); await expect(page.locator('#importType')).toBeVisible(); // Export section await expect(page.getByRole('heading', { name: 'Export Data' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible(); }); test('test that selecting an import type shows instructions', async ({ page }) => { await goToImportExport(page); // Select a Toggl import type await page.getByLabel('Import Type').selectOption({ index: 1 }); // Instructions should appear await expect(page.getByText('Instructions:')).toBeVisible(); }); test('test that importing without selecting type shows error', async ({ page }) => { await goToImportExport(page); // Click Import Data without selecting a type await page.getByRole('button', { name: 'Import Data' }).click(); // Should show an error notification await expect(page.getByText('Please select the import type')).toBeVisible(); }); test('test that importing without selecting file shows error', async ({ page }) => { await goToImportExport(page); // Select an import type first await page.getByLabel('Import Type').selectOption({ index: 1 }); // Click Import Data without selecting a file await page.getByRole('button', { name: 'Import Data' }).click(); // Should show an error notification await expect( page.getByText('Please select the CSV or ZIP file that you want to import') ).toBeVisible(); }); test('test that export button triggers export and shows success modal', async ({ page }) => { await goToImportExport(page); await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible(); // Override window.open to prevent the page from navigating away to the // download URL (the app uses window.open(url, '_self') which would navigate // away before we can verify the success modal) await page.evaluate(() => { window.open = () => null; }); // Click Export Organization Data and wait for the API response await Promise.all([ page.waitForResponse( (response) => response.url().includes('/export') && response.request().method() === 'POST' && response.status() === 200, { timeout: 60000 } ), page.getByRole('button', { name: 'Export Organization Data' }).click(), ]); // Success modal should appear after export completes await expect(page.getByText('The export was successful!')).toBeVisible(); }); test('test that import type dropdown has multiple options', async ({ page }) => { await goToImportExport(page); // The dropdown should load with options from the API await page.waitForResponse( (response) => response.url().includes('/importers') && response.request().method() === 'GET' && response.status() === 200 ); // Verify the select has options besides the default placeholder const options = page.getByLabel('Import Type').locator('option'); const count = await options.count(); // Should have at least the placeholder + some import types expect(count).toBeGreaterThan(1); }); test('test that importing a generic time entries CSV works', async ({ page }) => { await goToImportExport(page); await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 }); // Select "Generic Time Entries" import type await page.getByLabel('Import Type').selectOption({ label: 'Generic Time Entries' }); await expect(page.getByText('Instructions:')).toBeVisible(); // Upload the test CSV file const csvPath = path.resolve('resources/testfiles/generic_time_entries_import_test_1.csv'); await page.locator('#file-upload').setInputFiles(csvPath); // Click Import and wait for the API response await Promise.all([ page.waitForResponse( (response) => response.url().includes('/import') && response.request().method() === 'POST' && response.status() === 200, { timeout: 30000 } ), page.getByRole('button', { name: 'Import Data' }).click(), ]); // Verify success modal with import results await expect(page.getByRole('heading', { name: 'Import Result' })).toBeVisible(); await expect(page.getByText('The import was successful!')).toBeVisible(); // The CSV has 2 time entries, 1 client, 2 projects, 1 task await expect(page.getByText('Time entries created:').locator('..')).toContainText('2'); await expect(page.getByText('Projects created:').locator('..')).toContainText('2'); await expect(page.getByText('Clients created:').locator('..')).toContainText('1'); await expect(page.getByText('Tasks created:').locator('..')).toContainText('1'); }); // ============================================= // Employee Permission Tests // ============================================= test.describe('Employee Import Restrictions', () => { test('employee does not see Import / Export link in the sidebar', async ({ employee }) => { await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000, }); // The Import / Export link should NOT be visible in the sidebar for employees await expect( employee.page.getByRole('link', { name: 'Import / Export' }) ).not.toBeVisible(); }); }); ================================================ FILE: e2e/members.spec.ts ================================================ // TODO: Edit Billable Rate // TODO: Resend Email Invitation // TODO: Remove Invitation import { expect, test } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import type { Page } from '@playwright/test'; import { inviteAndAcceptMember } from './utils/members'; import { createPlaceholderMemberViaImportApi, getMembersViaApi, updateMemberBillableRateViaApi, updateOrganizationSettingViaApi, } from './utils/api'; import { getTableRowNames } from './utils/table'; // Tests that invite + accept members need more time test.describe.configure({ timeout: 45000 }); async function goToMembersPage(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/members'); } async function openInviteMemberModal(page: Page) { await Promise.all([ page.getByRole('button', { name: 'Invite Member' }).click(), expect(page.getByPlaceholder('Member Email')).toBeVisible(), ]); } test('test that new manager can be invited and accepted', async ({ page, browser }) => { const memberId = Math.round(Math.random() * 100000); const memberEmail = `manager+${memberId}@invite.test`; await inviteAndAcceptMember(page, browser, 'Invited Mgr', memberEmail, 'Manager'); // Verify the member appears in the members table with the correct role await goToMembersPage(page); const memberRow = page.getByRole('row').filter({ hasText: 'Invited Mgr' }); await expect(memberRow).toBeVisible(); await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible(); }); test('test that new employee can be invited and accepted', async ({ page, browser }) => { const memberId = Math.round(Math.random() * 100000); const memberEmail = `employee+${memberId}@invite.test`; await inviteAndAcceptMember(page, browser, 'Invited Emp', memberEmail, 'Employee'); // Verify the member appears in the members table with the correct role await goToMembersPage(page); const memberRow = page.getByRole('row').filter({ hasText: 'Invited Emp' }); await expect(memberRow).toBeVisible(); await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible(); }); test('test that new admin can be invited and accepted', async ({ page, browser }) => { const memberId = Math.round(Math.random() * 100000); const memberEmail = `admin+${memberId}@invite.test`; await inviteAndAcceptMember(page, browser, 'Invited Adm', memberEmail, 'Administrator'); // Verify the member appears in the members table with the correct role await goToMembersPage(page); const memberRow = page.getByRole('row').filter({ hasText: 'Invited Adm' }); await expect(memberRow).toBeVisible(); await expect(memberRow.getByText('Admin', { exact: true })).toBeVisible(); }); test('test that error shows if no role is selected', async ({ page }) => { await goToMembersPage(page); await openInviteMemberModal(page); const noRoleId = Math.round(Math.random() * 10000); await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`); await Promise.all([ page.getByRole('button', { name: 'Invite Member', exact: true }).click(), expect(page.getByText('Please select a role')).toBeVisible(), ]); }); test('test that organization billable rate can be updated with all existing time entries', async ({ page, }) => { await goToMembersPage(page); const newBillableRate = Math.round(Math.random() * 10000); await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').click(); await page.getByRole('combobox').last().click(); await page.getByRole('option', { name: 'Custom Rate' }).click(); await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString()); await page.getByRole('button', { name: 'Update Member' }).click(); await Promise.all([ page.getByRole('button', { name: 'Yes, update existing time' }).click(), page.waitForRequest( async (request) => request.url().includes('/members/') && request.method() === 'PUT' && request.postDataJSON().billable_rate === newBillableRate * 100 ), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.billable_rate === newBillableRate * 100 ), ]); }); test('test that switching member billable rate from custom back to default rate works', async ({ page, ctx, }) => { // Set a known org billable rate await updateOrganizationSettingViaApi(ctx, { billable_rate: 12000 }); // Create a placeholder member with a custom billable rate await createPlaceholderMemberViaImportApi(ctx, 'CustomToDefault Member'); const members = await getMembersViaApi(ctx); const member = members.find((m) => m.name === 'CustomToDefault Member'); expect(member).toBeDefined(); await updateMemberBillableRateViaApi(ctx, member!.id, 25000); await goToMembersPage(page); const memberRow = page.getByRole('row').filter({ hasText: 'CustomToDefault Member' }); await expect(memberRow).toBeVisible(); // Open edit modal await memberRow.getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').click(); await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible(); // Verify it starts on Custom Rate const billableCombobox = page.getByRole('dialog').getByRole('combobox').last(); await expect(billableCombobox).toContainText('Custom Rate'); // Switch to Default Rate await billableCombobox.click(); await page.getByRole('option', { name: 'Default Rate' }).click(); await expect(billableCombobox).toContainText('Default Rate'); // Verify the billable rate input is disabled await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled(); // Submit — billable_rate changes from 25000 to null, so confirmation dialog appears await page.getByRole('button', { name: 'Update Member' }).click(); await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible(); await expect(page.getByText('the default rate of the organization')).toBeVisible(); // Confirm the update await Promise.all([ page.getByRole('button', { name: 'Yes, update existing time' }).click(), page.waitForRequest( (request) => request.url().includes('/members/') && request.method() === 'PUT' && request.postDataJSON().billable_rate === null ), ]); // Verify both dialogs are closed await expect(page.getByRole('dialog')).not.toBeVisible(); }); test('test that default rate shows disabled input with organization billable rate', async ({ page, ctx, }) => { // Set a known org billable rate (150.00) await updateOrganizationSettingViaApi(ctx, { billable_rate: 15000 }); await goToMembersPage(page); // Open edit modal for the owner (who uses default rate by default) await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').click(); await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible(); // Verify it's on Default Rate const billableCombobox = page.getByRole('dialog').getByRole('combobox').last(); await expect(billableCombobox).toContainText('Default Rate'); // Verify the input is disabled and shows the org rate (formatted with currency) const billableInput = page.getByPlaceholder('Billable Rate'); await expect(billableInput).toBeDisabled(); await expect(billableInput).toHaveAttribute('aria-valuenow', '150'); // Close the dialog await page.getByRole('button', { name: 'Cancel' }).click(); await expect(page.getByRole('dialog')).not.toBeVisible(); }); test('test that cancelling the billable rate confirmation dialog does not update the member', async ({ page, ctx, }) => { // Create a placeholder member with a custom billable rate await createPlaceholderMemberViaImportApi(ctx, 'CancelConfirm Member'); const members = await getMembersViaApi(ctx); const member = members.find((m) => m.name === 'CancelConfirm Member'); expect(member).toBeDefined(); await updateMemberBillableRateViaApi(ctx, member!.id, 10000); await goToMembersPage(page); const memberRow = page.getByRole('row').filter({ hasText: 'CancelConfirm Member' }); await expect(memberRow).toBeVisible(); // Open edit modal await memberRow.getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').click(); await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible(); // Change the billable rate await page.getByPlaceholder('Billable Rate').fill('200'); // Click Update Member — confirmation dialog should appear await page.getByRole('button', { name: 'Update Member' }).click(); await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible(); // Set up listener to verify no PUT request is sent after cancel let putRequestSent = false; page.on('request', (request) => { if (request.url().includes('/members/') && request.method() === 'PUT') { putRequestSent = true; } }); // Click Cancel on the confirmation dialog await page.getByRole('button', { name: 'Cancel' }).click(); // Verify confirmation dialog is closed await expect( page.getByRole('heading', { name: 'Update Member Billable Rate' }) ).not.toBeVisible(); // Verify no API call was made expect(putRequestSent).toBe(false); }); test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => { const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000); // Create a placeholder member via import await createPlaceholderMemberViaImportApi(ctx, placeholderName); // Go to members page and verify placeholder exists with role "Placeholder" await goToMembersPage(page); const memberRow = page.getByRole('row').filter({ hasText: placeholderName }); await expect(memberRow).toBeVisible(); await expect(memberRow.getByText('Placeholder', { exact: true })).toBeVisible(); // Open the edit modal for the placeholder member await memberRow.getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible(); // Change role to Employee const roleSelect = page.getByRole('dialog').getByRole('combobox').first(); await roleSelect.click(); await expect(page.getByRole('option', { name: 'Employee' })).toBeVisible(); await page.getByRole('option', { name: 'Employee' }).click(); await expect(roleSelect).toContainText('Employee'); // Submit the change - the API should reject it with 400 await Promise.all([ page.getByRole('button', { name: 'Update Member' }).click(), page.waitForResponse( (response) => response.url().includes('/members/') && response.request().method() === 'PUT' && response.status() === 400 ), ]); // Verify error notification is shown await expect(page.getByText('Failed to update member')).toBeVisible(); }); test('test that changing member role updates the role in the member table', async ({ page, browser, }) => { const memberId = Math.floor(Math.random() * 100000); const memberEmail = `member+${memberId}@rolechange.test`; // Invite and accept a new Employee member await inviteAndAcceptMember(page, browser, 'Jane Smith', memberEmail, 'Employee'); // Verify the new member appears with the Employee role await goToMembersPage(page); const memberRow = page.getByRole('row').filter({ hasText: 'Jane Smith' }); await expect(memberRow).toBeVisible(); await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible(); // Open the edit modal await memberRow.getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible(); // Change role to Manager const roleSelect = page.getByRole('dialog').getByRole('combobox').first(); await roleSelect.click(); await expect(page.getByRole('option', { name: 'Manager' })).toBeVisible(); await page.getByRole('option', { name: 'Manager' }).click(); await expect(roleSelect).toContainText('Manager'); // Submit the change and verify the API call succeeds await Promise.all([ page.getByRole('button', { name: 'Update Member' }).click(), page.waitForResponse( (response) => response.url().includes('/members/') && response.request().method() === 'PUT' && response.status() === 200 ), ]); // Verify dialog closed await expect(page.getByRole('dialog')).not.toBeVisible(); // Verify the role updated in the table await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible(); }); test('test that merging a placeholder member works', async ({ page, ctx }) => { const placeholderName = 'Merge Target ' + Math.floor(Math.random() * 10000); // Create a placeholder member via import await createPlaceholderMemberViaImportApi(ctx, placeholderName); // Go to members page await goToMembersPage(page); await expect(page.getByText(placeholderName)).toBeVisible(); // Find the placeholder member row and open actions menu const placeholderRow = page.getByRole('row').filter({ hasText: placeholderName }); await placeholderRow.getByRole('button').click(); // Click Merge await page.getByTestId('member_merge').click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible(); // Select the current user (the owner) as merge target via MemberCombobox // The MemberCombobox renders a Button as trigger; clicking it opens the popover with the combobox input await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click(); // Wait for dropdown options to load const firstOption = page.getByRole('option').first(); await expect(firstOption).toBeVisible({ timeout: 10000 }); await firstOption.click(); // Submit merge await Promise.all([ page.getByRole('button', { name: 'Merge Member' }).click(), page.waitForResponse( (response) => response.url().includes('/member/') && response.url().includes('/merge-into') && response.ok() ), ]); // Wait for merge dialog to close after successful merge await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible(); // Verify placeholder member is no longer in the members table await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible(); }); test('test that deleting a placeholder member works', async ({ page, ctx }) => { const placeholderName = 'Delete Target ' + Math.floor(Math.random() * 10000); // Create a placeholder member via import await createPlaceholderMemberViaImportApi(ctx, placeholderName); // Go to members page await goToMembersPage(page); const memberRow = page.getByRole('row').filter({ hasText: placeholderName }); await expect(memberRow).toBeVisible(); // Open actions menu and click Delete await memberRow.getByRole('button').click(); await page.getByRole('menuitem').getByText('Delete').click(); // Verify delete modal is shown await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible(); // Try to delete without checking the confirmation checkbox await page.getByRole('button', { name: 'Delete Member' }).click(); // Should show validation error await expect( page.getByText('You must confirm that you understand the consequences of this action') ).toBeVisible(); // Check the confirmation checkbox await page.getByRole('checkbox').click(); // Click Delete Member button and wait for API response await Promise.all([ page.getByRole('button', { name: 'Delete Member' }).click(), page.waitForResponse( (response) => response.url().includes('/members/') && response.request().method() === 'DELETE' && response.ok() ), ]); // Verify modal is closed await expect(page.getByRole('dialog')).not.toBeVisible(); // Verify member is removed from the table await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible(); }); test('test that member delete modal can be cancelled', async ({ page, ctx }) => { const placeholderName = 'Delete Cancel ' + Math.floor(Math.random() * 10000); // Create a placeholder member via import await createPlaceholderMemberViaImportApi(ctx, placeholderName); // Go to members page await goToMembersPage(page); const memberRow = page.getByRole('row').filter({ hasText: placeholderName }); await expect(memberRow).toBeVisible(); // Open actions menu and click Delete await memberRow.getByRole('button').click(); await page.getByRole('menuitem').getByText('Delete').click(); // Verify delete modal is shown await expect(page.getByRole('dialog')).toBeVisible(); // Set up listener to verify no DELETE request is sent let deleteRequestSent = false; page.on('request', (request) => { if (request.url().includes('/members/') && request.method() === 'DELETE') { deleteRequestSent = true; } }); // Click Cancel await page.getByRole('button', { name: 'Cancel' }).click(); // Verify modal is closed await expect(page.getByRole('dialog')).not.toBeVisible(); // Verify member is still in the table await expect(memberRow).toBeVisible(); // Verify no DELETE request was sent expect(deleteRequestSent).toBe(false); }); test('test that organization owner cannot be deleted', async ({ page }) => { await goToMembersPage(page); // Find the owner row (John Doe with Owner role) const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' }); await expect(ownerRow).toBeVisible(); // Open the actions menu for the owner await ownerRow.getByRole('button').click(); // Click Delete await page.getByRole('menuitem').getByText('Delete').click(); // Verify delete modal is shown await expect(page.getByRole('dialog')).toBeVisible(); // Check the confirmation checkbox await page.getByRole('checkbox').click(); // Try to delete - should fail with 400 error const responsePromise = page.waitForResponse( (response) => response.url().includes('/members/') && response.request().method() === 'DELETE' ); await page.getByRole('button', { name: 'Delete Member' }).click(); const response = await responsePromise; // Verify the API returned an error status expect(response.status()).toBe(400); // Close the modal by pressing Escape await page.keyboard.press('Escape'); // Refresh and verify the owner is still there await goToMembersPage(page); await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible(); }); // ============================================= // Invitations Tab Tests // ============================================= test('test that invitation shows in invitations tab and can be revoked', async ({ page }) => { const inviteEmail = `invite+${Math.floor(Math.random() * 100000)}@pending.test`; await goToMembersPage(page); await openInviteMemberModal(page); await page.getByPlaceholder('Member Email').fill(inviteEmail); await page.getByRole('button', { name: 'Employee' }).click(); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/invitations') && response.request().method() === 'POST' && response.status() === 204 ), page.getByRole('button', { name: 'Invite Member', exact: true }).click(), ]); // Wait for modal to close await expect(page.getByPlaceholder('Member Email')).not.toBeVisible(); // Switch to Invitations tab and verify the invitation is visible await page.getByText('Invitations', { exact: true }).click(); await expect(page.getByText(inviteEmail)).toBeVisible(); // Find and click the actions menu for this invitation const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail }); await invitationRow.getByRole('button').click(); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/invitations/') && response.request().method() === 'DELETE' && response.status() === 204 ), page.getByRole('menuitem').getByText('Delete').click(), ]); // Verify invitation is removed await expect(page.getByText(inviteEmail)).not.toBeVisible(); }); test('test that invitation can be resent', async ({ page }) => { const inviteEmail = `resend+${Math.floor(Math.random() * 100000)}@invite.test`; await goToMembersPage(page); await openInviteMemberModal(page); await page.getByPlaceholder('Member Email').fill(inviteEmail); await page.getByRole('button', { name: 'Employee' }).click(); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/invitations') && response.request().method() === 'POST' && response.status() === 204 ), page.getByRole('button', { name: 'Invite Member', exact: true }).click(), ]); // Wait for modal to close await expect(page.getByPlaceholder('Member Email')).not.toBeVisible(); // Switch to Invitations tab await page.getByText('Invitations', { exact: true }).click(); await expect(page.getByText(inviteEmail)).toBeVisible(); // Find and click the actions menu, then resend const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail }); await invitationRow.getByRole('button').click(); // Wait for dropdown menu to appear await expect(page.getByRole('menuitem').getByText('Resend Invitation')).toBeVisible(); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/resend') && response.request().method() === 'POST' ), page.getByRole('menuitem').getByText('Resend Invitation').click(), ]); }); test('test that admin user cannot transfer ownership', async ({ page, browser }) => { const memberId = Math.floor(Math.random() * 100000); const memberEmail = `admin+${memberId}@perms.test`; // Invite and accept an admin member await inviteAndAcceptMember( page, browser, 'Admin User ' + memberId, memberEmail, 'Administrator' ); // Go to members page and verify the admin exists await goToMembersPage(page); const adminRow = page.getByRole('row').filter({ hasText: 'Admin User' }); await expect(adminRow).toBeVisible(); // The owner should still be the owner const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' }); await expect(ownerRow).toBeVisible(); // Open actions menu for the admin - should NOT have "Transfer Ownership" option await adminRow.getByRole('button').click(); await expect(page.getByRole('menuitem').getByText('Edit')).toBeVisible(); }); test('test that accepted invitation disappears from invitations tab', async ({ page, browser }) => { const memberId = Math.round(Math.random() * 100000); const memberEmail = `accepted+${memberId}@invite.test`; // Invite and accept the member await inviteAndAcceptMember(page, browser, 'Accepted Member', memberEmail, 'Employee'); // Go to members page and switch to Invitations tab await goToMembersPage(page); await page.getByRole('tab', { name: 'Invitations' }).click(); // The accepted invitation should not be visible await expect(page.getByText(memberEmail)).not.toBeVisible(); }); // ============================================= // Sorting Tests // ============================================= // Helper to clear localStorage before tests that check sorting async function clearMemberTableState(page: Page) { await page.evaluate(() => { localStorage.removeItem('member-table-state'); }); } test('test that sorting members by name, role, and status works', async ({ page, ctx }) => { // Create two placeholder members with names that sort predictably around "John Doe" await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst'); await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast'); await goToMembersPage(page); await clearMemberTableState(page); await page.reload(); const table = page.getByTestId('member_table'); await expect(table).toBeVisible(); // -- Name sorting (default is already name asc after clearing state) -- const nameHeader = table.getByText('Name').first(); let names = await getTableRowNames(table); expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast')); await nameHeader.click(); // toggle to desc names = await getTableRowNames(table); expect(names.indexOf('ZZZ SortLast')).toBeLessThan(names.indexOf('AAA SortFirst')); // -- Role sorting -- const roleHeader = table.getByText('Role').first(); await roleHeader.click(); // asc: Owner(0) < Placeholder(4) names = await getTableRowNames(table); const ownerIdx = names.indexOf('John Doe'); const placeholderIdx = names.indexOf('AAA SortFirst'); expect(ownerIdx).toBeLessThan(placeholderIdx); await roleHeader.click(); // desc: Placeholder first names = await getTableRowNames(table); expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe')); // -- Status sorting -- const statusHeader = table.getByText('Status').first(); await statusHeader.click(); // asc: Active(0) < Inactive(1) names = await getTableRowNames(table); expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst')); await statusHeader.click(); // desc: Inactive first names = await getTableRowNames(table); expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe')); // -- Email: just verify sort indicator appears -- const emailHeader = table.getByText('Email').first(); await emailHeader.click(); await expect(emailHeader.locator('svg')).toBeVisible(); }); test('test that member sort state persists after page reload', async ({ page }) => { await goToMembersPage(page); await clearMemberTableState(page); await page.reload(); const table = page.getByTestId('member_table'); await expect(table).toBeVisible(); // Click Role header twice to set descending sort const roleHeader = table.getByText('Role').first(); await roleHeader.click(); await expect(roleHeader.locator('svg')).toBeVisible(); await roleHeader.click(); await expect(roleHeader.locator('svg')).toBeVisible(); // Reload the page await page.reload(); // Verify the sort indicator is still visible on Role column await expect(page.getByTestId('member_table')).toBeVisible(); await expect( page.getByTestId('member_table').getByText('Role').first().locator('svg') ).toBeVisible(); }); test('test that sorting members by billable rate works', async ({ page, ctx }) => { // Create two placeholder members and set different billable rates await createPlaceholderMemberViaImportApi(ctx, 'HighRate Member'); await createPlaceholderMemberViaImportApi(ctx, 'LowRate Member'); const members = await getMembersViaApi(ctx); const highRateMember = members.find((m) => m.name === 'HighRate Member'); const lowRateMember = members.find((m) => m.name === 'LowRate Member'); expect(highRateMember).toBeDefined(); expect(lowRateMember).toBeDefined(); await updateMemberBillableRateViaApi(ctx, highRateMember!.id, 20000); await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000); await goToMembersPage(page); await clearMemberTableState(page); await page.reload(); const table = page.getByTestId('member_table'); await expect(table).toBeVisible(); // First click = desc (highest first), null rates last const billableHeader = table.getByText('Billable Rate').first(); await billableHeader.click(); await expect(billableHeader.locator('svg')).toBeVisible(); let names = await getTableRowNames(table); expect(names.indexOf('HighRate Member')).toBeLessThan(names.indexOf('LowRate Member')); // Second click = asc (lowest first), null rates still last await billableHeader.click(); names = await getTableRowNames(table); expect(names.indexOf('LowRate Member')).toBeLessThan(names.indexOf('HighRate Member')); }); // ============================================= // Employee Permission Tests // ============================================= test.describe('Employee Sidebar Navigation', () => { test('employee sidebar shows correct navigation links', async ({ employee }) => { await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000, }); // Visible links await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible(); await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible(); await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible(); await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible(); await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible(); await expect(employee.page.getByRole('link', { name: 'Tags' })).toBeVisible(); // Hidden links await expect(employee.page.getByRole('link', { name: 'Members' })).not.toBeVisible(); await expect( employee.page.getByRole('link', { name: 'Settings', exact: true }) ).not.toBeVisible(); }); test('employee cannot see members list or invite members', async ({ employee }) => { await employee.page.goto(PLAYWRIGHT_BASE_URL + '/members'); // Page loads but the members API returns 403 (no members:view permission) await expect(employee.page.getByRole('heading', { name: 'Members' })).toBeVisible({ timeout: 10000, }); // Member table is empty — no rows rendered (only headers) await expect(employee.page.getByTestId('member_table').locator('[role="row"]')).toHaveCount( 0 ); // Employee should NOT see the Invite Member button await expect( employee.page.getByRole('button', { name: 'Invite member' }) ).not.toBeVisible(); }); }); ================================================ FILE: e2e/organization.spec.ts ================================================ import { expect, test } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; async function goToOrganizationSettings(page) { await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); await page.locator('[data-testid="organization_switcher"]:visible').click(); await page.getByText('Organization Settings').click(); } async function createTimeEntry(page, duration: string) { await page.goto(PLAYWRIGHT_BASE_URL + '/time'); // Open the dropdown menu and click "Manual time entry" await page.getByRole('button', { name: 'Time entry actions' }).click(); await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); // Fill in the time entry details await page.getByTestId('time_entry_description').fill('Test time entry'); // Set duration await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); // Submit the time entry await Promise.all([ page.getByRole('button', { name: 'Create Time Entry' }).click(), page.waitForResponse( async (response) => response.url().includes('/time-entries') && response.request().method() === 'POST' && response.status() === 201 ), ]); } test('test that organization name can be updated', async ({ page }) => { await goToOrganizationSettings(page); await page.getByLabel('Organization Name').fill('NEW ORG NAME'); await page.getByLabel('Organization Name').press('Enter'); await page.getByLabel('Organization Name').press('Meta+r'); await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText( 'NEW ORG NAME' ); }); test('test that organization billable rate can be updated with all existing time entries', async ({ page, }) => { await goToOrganizationSettings(page); const newBillableRate = Math.round(Math.random() * 10000); await page.getByLabel('Organization Billable Rate').click(); await page.getByLabel('Organization Billable Rate').fill(newBillableRate.toString()); await page .locator('form') .filter({ hasText: 'Organization Billable' }) .getByRole('button', { name: 'Save' }) .click(); await Promise.all([ page.getByRole('button', { name: 'Yes, update existing time entries' }).click(), page.waitForRequest( async (request) => request.url().includes('/organizations/') && request.method() === 'PUT' && request.postDataJSON().billable_rate === newBillableRate * 100 ), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.billable_rate === newBillableRate * 100 ), ]); }); test('test that organization format settings can be updated', async ({ page }) => { await goToOrganizationSettings(page); // Test number format await page.getByLabel('Number Format').click(); await page.getByRole('option', { name: '1,111.11' }).click(); await Promise.all([ page .locator('form') .filter({ hasText: 'Number Format' }) .getByRole('button', { name: 'Save' }) .click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.number_format === 'comma-point' ), ]); // Test currency format await page.getByLabel('Currency Format').click(); await page.getByRole('option', { name: '111 EUR' }).click(); await Promise.all([ page .locator('form') .filter({ hasText: 'Currency Format' }) .getByRole('button', { name: 'Save' }) .click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.currency_format === 'iso-code-after-with-space' ), ]); // Test date format await page.getByLabel('Date Format').click(); await page.getByRole('option', { name: 'DD/MM/YYYY' }).click(); await Promise.all([ page .locator('form') .filter({ hasText: 'Date Format' }) .getByRole('button', { name: 'Save' }) .click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.date_format === 'slash-separated-dd-mm-yyyy' ), ]); // Test time format await page.getByLabel('Time Format').click(); await page.getByRole('option', { name: '24-hour clock' }).click(); await Promise.all([ page .locator('form') .filter({ hasText: 'Time Format' }) .getByRole('button', { name: 'Save' }) .click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.time_format === '24-hours' ), ]); // Test interval format await page.getByLabel('Time Duration Format').click(); await page.getByRole('option', { name: '12:03', exact: true }).click(); await Promise.all([ page .locator('form') .filter({ hasText: 'Time Duration Format' }) .getByRole('button', { name: 'Save' }) .click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.interval_format === 'hours-minutes-colon-separated' ), ]); }); test('test that format settings are reflected in the dashboard', async ({ page }) => { // check that 0h 00min is displayed await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible(); // First set the format settings await goToOrganizationSettings(page); // Set number format to comma-point await page.getByLabel('Number Format').click(); await page.getByRole('option', { name: '1,111.11' }).click(); // Set currency format to symbol-after await page.getByLabel('Currency Format').click(); await page.getByRole('option', { name: '111€' }).click(); // Set interval format to hours-minutes-colon-separated await page.getByLabel('Time Duration Format').click(); await page.getByRole('option', { name: '12:03', exact: true }).click(); // Set date format to DD/MM/YYYY await page.getByLabel('Date Format').click(); await page.getByRole('option', { name: 'DD/MM/YYYY' }).click(); await Promise.all([ page .locator('form') .filter({ hasText: 'Time Duration Format' }) .getByRole('button', { name: 'Save' }) .click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.interval_format === 'hours-minutes-colon-separated' && (await response.json()).data.currency_format === 'symbol-after' && (await response.json()).data.number_format === 'comma-point' ), ]); await createTimeEntry(page, '00:00'); // Go to dashboard and check the formats await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); // Check billable amount format (number and currency) await expect(page.getByText('0.00€')).toBeVisible(); // check that 00:00 is displayed await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible(); // check that 0h 00min is not displayed await expect(page.getByText('0h 00min', { exact: true }).nth(0)).not.toBeVisible(); // check that the current date is displayed in the dd/mm/yyyy format on the time page await page.goto(PLAYWRIGHT_BASE_URL + '/time'); // Wait for time entries to load so organization data is available for date formatting await page.waitForResponse( (response) => response.url().includes('/time-entries') && response.status() === 200 ); await expect( page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0) ).toBeVisible({ timeout: 10000 }); }); test('test that organization time entry settings can be toggled', async ({ page }) => { await goToOrganizationSettings(page); const preventOverlappingCheckbox = page.getByLabel( 'Prevent overlapping time entries (new entries only)' ); const manageTasksCheckbox = page.getByLabel('Allow Employees to manage tasks'); // Get current states and toggle both const wasOverlappingChecked = await preventOverlappingCheckbox.isChecked(); const wasManageTasksChecked = await manageTasksCheckbox.isChecked(); if (wasOverlappingChecked) { await preventOverlappingCheckbox.uncheck(); } else { await preventOverlappingCheckbox.check(); } if (wasManageTasksChecked) { await manageTasksCheckbox.uncheck(); } else { await manageTasksCheckbox.check(); } // Save const settingsForm = page.locator('form').filter({ hasText: 'Prevent overlapping' }); await Promise.all([ settingsForm.getByRole('button', { name: 'Save' }).click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.prevent_overlapping_time_entries === !wasOverlappingChecked ), ]); // Reload and verify both settings persisted await page.reload(); await expect(preventOverlappingCheckbox).toBeChecked({ checked: !wasOverlappingChecked }); await expect(manageTasksCheckbox).toBeChecked({ checked: !wasManageTasksChecked }); // Toggle both back to restore original state if (!wasOverlappingChecked) { await preventOverlappingCheckbox.uncheck(); } else { await preventOverlappingCheckbox.check(); } if (!wasManageTasksChecked) { await manageTasksCheckbox.uncheck(); } else { await manageTasksCheckbox.check(); } await Promise.all([ settingsForm.getByRole('button', { name: 'Save' }).click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.prevent_overlapping_time_entries === wasOverlappingChecked ), ]); }); test('test that 12-hour clock format can be set', async ({ page }) => { await goToOrganizationSettings(page); await page.getByLabel('Time Format').click(); await page.getByRole('option', { name: '12-hour clock' }).click(); await Promise.all([ page .locator('form') .filter({ hasText: 'Time Format' }) .getByRole('button', { name: 'Save' }) .click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.time_format === '12-hours' ), ]); // Reload and verify it persisted await page.reload(); await expect(page.getByLabel('Time Format')).toContainText('12-hour clock'); // Reset back to 24-hour await page.getByLabel('Time Format').click(); await page.getByRole('option', { name: '24-hour clock' }).click(); await Promise.all([ page .locator('form') .filter({ hasText: 'Time Format' }) .getByRole('button', { name: 'Save' }) .click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.time_format === '24-hours' ), ]); }); test('test that format settings persist after page reload', async ({ page }) => { await goToOrganizationSettings(page); // Set a specific date format await page.getByLabel('Date Format').click(); await page.getByRole('option', { name: 'DD/MM/YYYY' }).click(); await Promise.all([ page .locator('form') .filter({ hasText: 'Date Format' }) .getByRole('button', { name: 'Save' }) .click(), page.waitForResponse( async (response) => response.url().includes('/organizations/') && response.request().method() === 'PUT' && response.status() === 200 ), ]); // Reload and verify it persisted await page.reload(); await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY'); }); // ============================================= // Admin Permission Tests // ============================================= test.describe('Admin Organization Settings Access', () => { test('admin can see and edit organization settings', async ({ ctx, admin }) => { await admin.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId); // Organization Name section is visible await expect( admin.page.getByRole('heading', { name: 'Organization Name', level: 3 }) ).toBeVisible({ timeout: 10000 }); // Editable settings sections should be visible await expect( admin.page.getByRole('heading', { name: 'Billable Rate', level: 3 }) ).toBeVisible(); await expect( admin.page.getByRole('heading', { name: 'Format Settings', level: 3 }) ).toBeVisible(); await expect( admin.page.getByRole('heading', { name: 'Organization Settings', level: 3 }) ).toBeVisible(); // Save buttons should be visible (admin can update) await expect(admin.page.getByRole('button', { name: 'Save' }).first()).toBeVisible(); // Delete organization should NOT be visible (owner only) await expect( admin.page.getByRole('heading', { name: 'Delete Organization' }) ).not.toBeVisible(); }); }); // ============================================= // Employee Permission Tests // ============================================= test.describe('Employee Organization Settings Restrictions', () => { test('employee can see org name but not editable settings', async ({ ctx, employee }) => { await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId); // Organization Name section is visible (but inputs are disabled) await expect( employee.page.getByRole('heading', { name: 'Organization Name', level: 3 }) ).toBeVisible({ timeout: 10000 }); // Editable settings sections should NOT be visible await expect( employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 }) ).not.toBeVisible(); await expect( employee.page.getByRole('heading', { name: 'Format Settings', level: 3 }) ).not.toBeVisible(); await expect( employee.page.getByRole('heading', { name: 'Organization Settings', level: 3 }) ).not.toBeVisible(); // Save button should not be visible (employee cannot update) await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible(); }); }); ================================================ FILE: e2e/profile.spec.ts ================================================ import { test, expect } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config'; import type { Page } from '@playwright/test'; async function goToProfilePage(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); } test('test that user name can be updated', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.getByLabel('Name', { exact: true }).fill('NEW NAME'); await Promise.all([ page.getByRole('button', { name: 'Save' }).first().click(), page.waitForResponse('**/user/profile-information'), ]); await page.reload(); await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME'); }); test.skip('test that user email can be updated', async ({ page }) => { // this does not work because of email verification currently await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); const emailId = Math.round(Math.random() * 10000); await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`); await page.getByRole('button', { name: 'Save' }).first().click(); await page.reload(); await expect(page.getByLabel('Email')).toHaveValue(`newemail+${emailId}@test.com`); }); async function createNewApiToken(page) { await page.getByLabel('API Key Name').fill('NEW API KEY'); await Promise.all([ page.getByRole('button', { name: 'Create API Key' }).click(), page.waitForResponse('**/users/me/api-tokens'), ]); await expect(page.locator('body')).toContainText('API Token created successfully'); await page.getByRole('dialog').getByText('Close').click(); await expect(page.locator('body')).toContainText('NEW API KEY'); } test('test that user can create an API key', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await createNewApiToken(page); }); test('test that creating an API key with empty name shows validation error', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); // Wait for the API Key Name input to be visible before interacting const nameInput = page.getByLabel('API Key Name'); await expect(nameInput).toBeVisible(); // Ensure the API Key Name input is empty await nameInput.fill(''); // Click the create button and wait for the 422 response const [response] = await Promise.all([ page.waitForResponse('**/users/me/api-tokens'), page.getByRole('button', { name: 'Create API Key' }).click(), ]); expect(response.status()).toBe(422); // Verify that an error notification is shown with validation message about the name field await expect(page.getByText('name field is required')).toBeVisible({ timeout: 5000 }); }); test('test that user can delete an API key', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await createNewApiToken(page); page.getByLabel('Delete API Token NEW API KEY').click(); await expect(page.getByRole('dialog')).toContainText( 'Are you sure you would like to delete this API token?' ); await Promise.all([ page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(), page.waitForResponse('**/users/me/api-tokens'), ]); await expect(page.locator('body')).not.toContainText('NEW API KEY'); }); test('test that user can revoke an API key', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await createNewApiToken(page); page.getByLabel('Revoke API Token NEW API KEY').click(); await expect(page.getByRole('dialog')).toContainText( 'Are you sure you would like to revoke this API token?' ); await Promise.all([ page.getByRole('dialog').getByRole('button', { name: 'Revoke' }).click(), page.waitForResponse('**/users/me/api-tokens'), ]); await expect(page.getByRole('button', { name: 'Revoke' })).toBeHidden(); await expect(page.locator('body')).toContainText('NEW API KEY'); await expect(page.locator('body')).toContainText('Revoked'); }); // ============================================= // Update Password Form Tests // ============================================= test('test that password mismatch shows error', async ({ page }) => { await goToProfilePage(page); // Fill in with mismatched passwords await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD); await page.getByLabel('New Password').fill('newSecurePassword456'); await page.getByLabel('Confirm Password').fill('differentPassword789'); // Find the form containing the Confirm Password field and click its Save button const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/user/password') && response.request().method() === 'PUT' ), passwordForm.getByRole('button', { name: 'Save' }).click(), ]); // Verify error message about password confirmation await expect(page.getByText('confirmation does not match')).toBeVisible(); }); test('test that short password shows validation error', async ({ page }) => { await goToProfilePage(page); // Fill in with a too short password await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD); await page.getByLabel('New Password').fill('short'); await page.getByLabel('Confirm Password').fill('short'); // Find the form containing the Confirm Password field and click its Save button const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/user/password') && response.request().method() === 'PUT' ), passwordForm.getByRole('button', { name: 'Save' }).click(), ]); // Verify error message about password length await expect(page.getByText('must be at least')).toBeVisible(); }); test('test that incorrect current password shows validation error', async ({ page }) => { await goToProfilePage(page); // Fill in with wrong current password await page.getByLabel('Current Password').fill('wrongCurrentPassword123'); await page.getByLabel('New Password').fill('newSecurePassword456'); await page.getByLabel('Confirm Password').fill('newSecurePassword456'); // Find the form containing the Confirm Password field and click its Save button const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/user/password') && response.request().method() === 'PUT' ), passwordForm.getByRole('button', { name: 'Save' }).click(), ]); // Verify error message about incorrect password await expect(page.getByText('does not match')).toBeVisible(); }); test('test that password can be updated successfully', async ({ page }) => { await goToProfilePage(page); const newPassword = 'newSecurePassword456'; // Change password to new password await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD); await page.getByLabel('New Password').fill(newPassword); await page.getByLabel('Confirm Password').fill(newPassword); const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); const responsePromise = page.waitForResponse( (response) => response.url().includes('/user/password') && response.request().method() === 'PUT' ); await passwordForm.getByRole('button', { name: 'Save' }).click(); const response = await responsePromise; // Verify successful response (303 is Inertia redirect on success, means password was updated) expect(response.status()).toBe(303); // Verify no error messages are displayed await expect(page.getByText('does not match')).not.toBeVisible(); await expect(page.getByText('must be at least')).not.toBeVisible(); }); // ============================================= // Theme Selection Tests // ============================================= test('test that theme can be changed to dark and light', async ({ page }) => { await goToProfilePage(page); // The theme select is a Reka UI combobox (button), not a native