From 40247efddd0a46ef9e2302e3e889306d85077d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= <43389096+Asuniia@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:08:38 +0200 Subject: [PATCH] feat/onboarding (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(onboarding): add cookie helper, types and mock data * feat(onboarding): add stepper, contextual preview and shell * feat(onboarding): add sso-gate step * feat(onboarding): add account-info step with real signup * feat(onboarding): add security step * feat(onboarding): add preferences step * fix(onboarding): validate avatar upload size/type and handle FileReader errors * feat(onboarding): add org-create step * feat(onboarding): add invite-members step * feat(onboarding): add notifier step reusing real provider list * feat(onboarding): add storage step reusing real provider list * feat(onboarding): add defaults step * feat(onboarding): add agent-create step * feat(onboarding): add agent-waiting step with simulated ping * feat(onboarding): add project-create step * feat(onboarding): add db-settings step with per-db repeat * feat(onboarding): add finish step with confetti * feat(onboarding): wire full step graph * feat(onboarding): add /welcome route * feat(onboarding): gate landing, auth and dashboard routes behind onboarding cookie * fix(onboarding): call next() in finish step so onFlowComplete fires and cookie is written * docs: add onboarding channel config + style refactor spec * docs: add conditional step skip when no agents to spec * docs(onboarding): add implementation plan for channel config flow and style refactor * feat(onboarding): extend OnboardingChannel with name and config fields * feat(onboarding): add two-phase config flow and org-combobox style to notifier step * fix(onboarding): prevent duplicate notifier providers and guard Continue button * feat(onboarding): add two-phase config flow and org-combobox style to storage step * style(onboarding): restyle sso-gate provider buttons to org-combobox card style * style(onboarding): restyle security method choice to org-combobox card style * style(onboarding): restyle theme toggles in preferences step to org-combobox card style * style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style * feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created * feat(onboarding): update type system — meta, firstName/lastName, org.id, databases * feat(onboarding): add settings.onboarding column + markOnboardingDoneAction * feat(onboarding): add resolveOnboardingState server function * fix(onboarding): remove unused imports from onboarding-state.ts * style(onboarding): add branding to shell, fix progress bar full width * feat(onboarding): replace preview panel with stepper checklist * feat(onboarding): add step-login replacing sso-gate * fix(onboarding): step-login — conform to global mutation pattern, handle SSO errors * feat(onboarding): step-account-info — firstName/lastName, conditional password, findOrCreate * feat(onboarding): avatar API route (next/og) + AvatarPicker in preferences step * feat(onboarding): step-security reads passkeyEnabled from flowData.meta * feat(onboarding): step-org-create calls real createOrganizationAction * fix(onboarding): step-org-create — simplify error handling, throw on empty name * feat(onboarding): notifier + storage steps call real actions + findOrCreate on back * feat(onboarding): step-agent-create calls real createAgentAction + findOrCreate * feat(onboarding): step-agent-waiting polls real agent lastContact via useQuery * feat(onboarding): add step-agent-key with edgeKey generation via server action * feat(onboarding): step-project-create uses real action + real DBs from flowData * feat(onboarding): wire steps — add login + agent-key, remove sso-gate * feat(onboarding): page.tsx uses resolveOnboardingState — server-driven resume * chore(onboarding): delete mock/cookie/preview/sso-gate files — replaced by real implementation * feat(onboarding): add emailPasswordEnabled to meta, remove logoDataUrl from org * feat(onboarding): DB-driven resume — query notifiers/storages, check agent lastContact * feat(onboarding): agent-waiting step is now skippable * feat(onboarding): login — auto-advance if session, callbackURL on SSO, hide email/pw when disabled, empty state * fix(onboarding): step-login — add next to useEffect deps, default emailPasswordEnabled to false * fix(onboarding): verify session after passkey registration, fallback to signIn.passkey() * feat(onboarding): theme applied dynamically via setTheme, avatar saved to user.image * feat(onboarding): remove org logo upload — orgs have no logo * feat(onboarding): show Next button in shell when navigating backwards * docs: add onboarding refactor design spec * docs: add onboarding refactor implementation plan * refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks * refactor(onboarding): extract useCreateOrg and useCreateProject hooks * fix(onboarding): trim project name, standardize error narrowing in org/project hooks * fix(onboarding): use narrowed createData.value in use-create-org * refactor(onboarding): extract useUpdateAccount hook * refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks * refactor(onboarding): extract useAddStorage and useRemoveStorage hooks * refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks * refactor(onboarding): extract useMarkOnboardingDone hook, remove .gitkeep files * docs: add onboarding theme persistence & progress bar fix spec * docs: add onboarding theme & progress bar implementation plan * fix(onboarding): persist theme to db on selection in StepPreferences * fix(onboarding): recalculate progress bar from full STEP_ORDER position * fix(theme): sync user theme from DB on all routes, not only dashboard * fix(theme): use current theme as fallback in preferences, prevent session override of localStorage * fix(onboarding): correct resume steps and skip agent-waiting flash when agent is connected * fix(onboarding): properly resolve onboarding state, remove redirect trap in step-defaults, block waiting flash * fix(onboarding): resume at db-settings instead of finish if no database exists for the project * fix(onboarding): redirect to agent-create if project exists but no agent is found * fix(onboarding): allow going from agent-create to project-create if a project already exists * fix(onboarding): skip intermediate agent steps when going back from project-create to agent-create * fix(onboarding): skip default step when going back from agent-create if no notifiers and storages exist * feat(onboarding): allow user to choose between passkey and password during registration if both are enabled * feat(onboarding): replace tabs with single form and multiple buttons for auth choice * style(onboarding): improve empty auth state UI with lucide icon and better layout * fix(onboarding): completely hide back button on the first step instead of disabling it * fix(onboarding): skip default step if no notifiers and storages exist when advancing from storage step * fix(onboarding): correctly show select placeholder when default value does not exist in the list * refactor(onboarding): remove duplicate generate-edge-key action and use core utility directly * feat(onboarding): compute edgeKey directly on the server side to avoid lazy loading * fix(onboarding): skip db-settings step if no database is found on the agent * fix(onboarding): do not skip db settings if agent is connected but no databases found * refactor(onboarding): skip db settings entirely if no database is selected in project create * feat(onboarding): auto-save database selections on project create instantly * style(onboarding): replace button opacity flash with specific spinner animation on db select * feat(onboarding): extend OnboardingDbSettings with retention, schedule, and policy fields * feat(onboarding): add applyOnboardingDbSettingsAction server action * feat(onboarding): add BackupScheduleSelector component * feat(onboarding): rewrite step-db-settings with three-level phase navigation and full policy persistence * fix(onboarding): wire applyMutation.mutateAsync everywhere and stabilize list keys * docs(sdd): append hotfix summary for applyMutation and list keys * fix(onboarding): address final review findings - atomicity, scheduling guard, apply-all recovery, client validation * add: onboarding * fix(migration): merge * fix(migration): apply for onboarding * fix(settings): make avatar selector, dicebear picker and tab bar responsive * chore: checkpoint before features reorganization * Task 2: Add migration script for features directory reorganization * refactor(agents): reorganize into actions/components/schemas/hooks * refactor(auth): reorganize into actions/components/schemas * refactor(channel): reorganize into components/schemas, nest notifications+storages under components * refactor(database): reorganize into actions/components/schemas * refactor(layout): move all components to components/ * refactor(logs): reorganize into components/types * refactor(migration): reorganize into actions/components * refactor(notifications): reorganize into components/types/utils * refactor(organizations): reorganize into actions/components/schemas/hooks/utils * refactor(profile): reorganize into actions/components/schemas * refactor(projects): reorganize into actions/components/schemas * refactor(settings): reorganize into actions/components/schemas/hooks * refactor(statistics): reorganize into components/utils * refactor(storages): reorganize into types/utils * refactor(theme): move all components to components/ * refactor(updates): reorganize into components/hooks * refactor(upload): move action to actions/ * refactor(users): reorganize into actions/components/schemas * fix: update broken imports after features reorganization (double-quote relative imports, channel subdir absolute paths) * fix: .gitignore * fix: onboarding * fix: removed dead files * feat: backfill onboarding flag for instances with existing data * fix: persist onboarding=true on boot when SKIP_ONBOARDING is set * feat: remove avatarMode/dicebearStyle from DB schema and types * feat: simplify resolveAvatarUrl — custom image first, gravatar fallback * feat: allow avatar upload regardless of mode * feat: remove avatarMode from layout and profile prop chains * feat: remove avatar mode admin tab and supporting files * feat: remove avatar mode selector from onboarding defaults step * fix: remove preferences onboarding step and fix routing * fix: remove stale preferences entry from onboarding checklist * feat: add avatar reset button and explanatory tooltip on profile page * fix: move avatar tooltip to top-right badge overlay on avatar * fix: use small destructive button for avatar removal * fix * Update step-defaults.tsx * fix: default dev password for keycloak refer to env * fix * fix(migration): refactoring * fix: api agent helpers.ts * Update .env.example --------- Co-authored-by: Théo LAGACHE Co-authored-by: charles-gauthereau --- .env.example | 3 + .eslintrc.json | 6 +- .gitignore | 1 + README.md | 1 + app/(auth)/forgot-password/page.tsx | 4 +- app/(auth)/guard/page.tsx | 4 +- app/(auth)/layout.tsx | 8 +- app/(auth)/login/page.tsx | 6 +- app/(auth)/register/page.tsx | 2 +- app/(auth)/reset-password/page.tsx | 4 +- .../organizations/[organizationId]/page.tsx | 4 +- .../(admin)/admin/organizations/page.tsx | 6 +- .../dashboard/(admin)/admin/settings/page.tsx | 4 +- .../dashboard/(admin)/admin/users/page.tsx | 29 +- .../(admin)/agents/[agentId]/page.tsx | 8 +- .../dashboard/(admin)/agents/page.tsx | 6 +- .../(admin)/notifications/channels/page.tsx | 6 +- .../(admin)/notifications/logs/page.tsx | 4 +- .../(admin)/storages/channels/page.tsx | 6 +- .../(organization)/migration/page.tsx | 4 +- .../database/[databaseId]/page.tsx | 221 +- .../projects/[projectId]/page.tsx | 201 +- .../(organization)/projects/page.tsx | 6 +- .../settings/agents/[agentId]/page.tsx | 8 +- .../(organization)/settings/page.tsx | 8 +- .../(organization)/statistics/page.tsx | 6 +- app/(customer)/dashboard/home/page.tsx | 2 +- app/(customer)/dashboard/layout.tsx | 16 +- app/(landing)/page.tsx | 8 +- app/(welcome)/welcome/onboarding-client.tsx | 29 + app/(welcome)/welcome/page.tsx | 19 + app/api/agent/[agentId]/backup/route.ts | 4 +- .../[agentId]/backup/upload/init/route.ts | 141 +- app/api/agent/[agentId]/restore/route.ts | 216 +- app/api/agent/[agentId]/status/helpers.ts | 8 +- app/api/agent/[agentId]/status/route.ts | 182 +- app/api/auth/[...all]/route.ts | 42 +- app/api/files/backups/route.ts | 4 +- app/api/files/images/[fileName]/route.ts | 4 +- app/api/v1/agents/[id]/route.ts | 2 +- app/api/v1/agents/route.ts | 2 +- app/not-found.tsx | 7 +- app/providers.tsx | 6 +- docker-compose.prod.yml | 12 +- next.config.ts | 162 +- package.json | 3 + pnpm-lock.yaml | 58 +- portabase.config.ts | 2 +- proxy.ts | 5 +- seeds/keycloak/master-realm.json | 80 +- src/components/common/back-button.tsx | 18 - src/components/common/theme-selector.tsx | 122 + src/components/emails/email-notification.tsx | 4 +- .../migrations/0065_mighty_killer_shrike.sql | 1 + src/db/migrations/0066_milky_puma.sql | 11 + src/db/migrations/meta/0065_snapshot.json | 2977 +++++++++++++++++ src/db/migrations/meta/0066_snapshot.json | 2977 +++++++++++++++++ src/db/migrations/meta/_journal.json | 14 + src/db/schema/01_setting.ts | 3 +- src/db/services/agent.ts | 68 +- src/db/services/backup.ts | 26 +- src/db/services/database.ts | 152 +- src/db/services/healthcheck.ts | 329 +- src/db/services/notification-channel.ts | 88 +- src/db/services/notification-log.ts | 156 +- src/db/services/organization.ts | 29 + src/db/services/project.ts | 124 +- src/db/services/setting.ts | 12 + src/db/services/storage-channel.ts | 78 +- src/db/services/user.ts | 73 +- src/env.mjs | 4 + .../{ => actions}/agent-delete.action.ts | 0 .../agent-organizations.action.ts | 0 .../agents/{ => actions}/agents.action.ts | 2 +- .../agents/{ => actions}/keys.action.ts | 0 .../{ => components}/agent-card-key.tsx | 0 .../agents/{ => components}/agent-card.tsx | 2 +- .../agents/{ => components}/agent-content.tsx | 8 +- .../{ => components}/agent-database-card.tsx | 0 .../agent-database-columns.tsx | 0 .../{ => components}/agent-delete-button.tsx | 2 +- .../agents/{ => components}/agent-dialog.tsx | 4 +- .../agents/{ => components}/agent-form.tsx | 4 +- .../{ => components}/agent-key-modal.tsx | 0 .../agent-organizations.form.tsx | 4 +- .../{ => hooks}/use-agent-update-check.ts | 0 .../agent-organizations.schema.ts | 0 .../agents/{ => schemas}/agents.schema.ts | 0 .../{ => actions}/forgot-password.action.ts | 0 .../{ => actions}/reset-password.action.ts | 0 .../{ => components}/auth-logo-section.tsx | 0 .../{ => components}/forgot-password-form.tsx | 2 +- .../auth/{ => components}/guard-form.tsx | 2 +- .../auth/{ => components}/login-form.tsx | 2 +- .../auth/{ => components}/register-form.tsx | 4 +- .../{ => components}/reset-password-form.tsx | 2 +- .../auth/components/social-buttons.tsx | 104 + .../forgot-password-form.schema.ts | 0 .../auth/{ => schemas}/login-form.schema.ts | 0 .../{ => schemas}/register-form.schema.ts | 0 .../reset-password-form.schema.ts | 0 .../reset-password-page.schema.ts | 0 src/features/auth/social-buttons.tsx | 86 - src/features/channel/channel-card.tsx | 88 - .../channel-add-edit-modal.tsx | 6 +- .../channel/components/channel-card.tsx | 86 + .../channel-delete-button.tsx | 6 +- .../{ => components}/channel-edit-button.tsx | 8 +- .../channel/{ => components}/channel-form.tsx | 14 +- .../{ => components}/channel-test-button.tsx | 10 +- .../{ => components}/channels-helpers.tsx | 30 +- .../channels-notification-helper.tsx | 2 +- .../{ => components}/channels-section.tsx | 6 +- .../channels-storage-helper.tsx | 2 +- .../notifications/channel.action.ts | 200 ++ .../notifications/discord.form.tsx | 0 .../notifications/discord.schema.ts | 0 .../{ => components}/notifications/discord.ts | 2 +- .../notifications/gotify.form.tsx | 0 .../notifications/gotify.schema.ts | 0 .../{ => components}/notifications/gotify.ts | 2 +- .../{ => components}/notifications/index.ts | 18 +- .../notifications/nextcloud.form.tsx | 0 .../notifications/nextcloud.schema.ts | 0 .../notifications/nextcloud.ts | 2 +- .../notifications/ntfy.form.tsx | 0 .../notifications/ntfy.schema.ts | 0 .../{ => components}/notifications/ntfy.ts | 2 +- .../notifications/pushover.form.tsx | 2 +- .../notifications/pushover.schema.ts | 0 .../notifications/pushover.ts | 2 +- .../notifications/slack.form.tsx | 0 .../notifications/slack.schema.ts | 0 .../{ => components}/notifications/slack.ts | 2 +- .../notifications/smtp.form.tsx | 0 .../notifications/smtp.schema.ts | 0 .../{ => components}/notifications/smtp.ts | 2 +- .../notifications/teams.form.tsx | 0 .../notifications/teams.schema.ts | 0 .../{ => components}/notifications/teams.ts | 2 +- .../notifications/telegram.form.tsx | 0 .../notifications/telegram.schema.ts | 0 .../notifications/telegram.ts | 2 +- .../notifications/webhook.form.tsx | 0 .../notifications/webhook.schema.ts | 0 .../{ => components}/notifications/webhook.ts | 2 +- .../storages/az-blob.form.tsx | 0 .../storages/az-blob.schema.ts | 0 .../{ => components}/storages/az-blob.ts | 2 +- .../components/storages/channel.action.ts | 189 ++ .../google-drive-refresh.action.ts | 0 .../google-drive/google-drive.form.tsx | 2 +- .../google-drive/google-drive.schema.ts | 0 .../storages/google-drive/helpers.ts | 2 +- .../storages/google-drive/index.ts | 8 +- .../storages/google-drive/types.ts | 0 .../{ => components}/storages/index.ts | 8 +- .../{ => components}/storages/local.schema.ts | 0 .../{ => components}/storages/local.ts | 4 +- .../{ => components}/storages/s3.form.tsx | 0 .../{ => components}/storages/s3.schema.ts | 0 .../channel/{ => components}/storages/s3.ts | 2 +- .../channel/notifications/channel.action.ts | 172 - .../{ => schemas}/channel-form.schema.ts | 28 +- .../channel/storages/channel.action.ts | 173 - .../{ => actions}/backup-actions.action.ts | 4 +- .../{ => actions}/backup-button.action.ts | 0 .../{ => actions}/backup-get-data.action.ts | 0 .../{ => actions}/channels-policy.action.ts | 2 +- .../database/{ => actions}/cron.action.ts | 0 .../{ => actions}/database-form.action.ts | 2 +- .../database/{ => actions}/import.action.ts | 2 +- .../database/{ => actions}/restore.action.ts | 0 .../{ => actions}/retention-policy.action.ts | 2 +- .../database/channels-policy-modal.tsx | 77 - .../{ => components}/backup-actions-cell.tsx | 2 +- .../{ => components}/backup-actions-form.tsx | 10 +- .../{ => components}/backup-actions-modal.tsx | 4 +- .../{ => components}/backup-button.tsx | 2 +- .../{ => components}/backup-columns.tsx | 6 +- .../{ => components}/backup-modal-context.tsx | 0 .../{ => components}/channels-policy-form.tsx | 307 +- .../components/channels-policy-modal.tsx | 173 + .../{ => components}/cron-advanced-select.tsx | 0 .../database/{ => components}/cron-button.tsx | 4 +- .../database/{ => components}/cron-input.tsx | 4 +- .../{ => components}/database-backup-list.tsx | 4 +- .../{ => components}/database-content.tsx | 26 +- .../{ => components}/database-form.tsx | 4 +- .../{ => components}/database-kpi.tsx | 0 .../database-restore-list.tsx | 4 +- .../{ => components}/database-tabs.tsx | 4 +- .../database/{ => components}/health-grid.tsx | 0 .../{ => components}/health-modal.tsx | 2 +- .../{ => components}/import-modal.tsx | 2 +- .../{ => components}/import-upload-zone.tsx | 2 +- .../{ => components}/restore-columns.tsx | 4 +- .../components/retention-policy-form.tsx | 294 ++ .../components/retention-policy-sheet.tsx | 85 + .../{ => components}/retention-policy.tsx | 2 +- .../database/retention-policy-form.tsx | 306 -- .../database/retention-policy-sheet.tsx | 49 - .../{ => schemas}/backup-actions.schema.ts | 0 .../{ => schemas}/channels-policy.schema.ts | 0 .../{ => schemas}/database-form.schema.ts | 0 .../{ => schemas}/retention-policy.schema.ts | 0 .../layout/{ => components}/app-sidebar.tsx | 10 +- .../layout/{ => components}/card-auth.tsx | 0 .../{ => components}/demo-reset-banner.tsx | 0 .../layout/{ => components}/header.tsx | 4 +- .../logged-in-button.server.tsx | 13 +- .../{ => components}/logged-in-button.tsx | 10 +- .../{ => components}/logged-in-dropdown.tsx | 7 +- .../layout/{ => components}/logo-sidebar.tsx | 0 .../{ => components}/menu-sidebar-main.tsx | 2 +- .../layout/{ => components}/menu-sidebar.tsx | 0 src/features/layout/{ => components}/page.tsx | 0 .../layout/{ => components}/profile-modal.tsx | 15 +- .../{ => components}/profile-sidebar.tsx | 0 .../side-bar-footer-credit.tsx | 0 .../logs/{ => components}/log-row-modal.tsx | 0 .../{ => components}/logs-modal-context.tsx | 0 .../{ => components}/logs-modal-trigger.tsx | 2 +- .../logs/{ => components}/logs-modal.tsx | 4 +- .../logs/{types.ts => types/index.ts} | 0 .../{ => actions}/migration.action.ts | 2 +- .../{ => components}/migration-flow.tsx | 0 .../{ => components}/migration-tool.tsx | 8 +- .../{ => components}/source-panel.tsx | 0 .../{ => components}/target-panel.tsx | 0 .../notification-log-columns.tsx | 4 +- .../notification-log-modal.tsx | 0 .../notification-logs-list.tsx | 2 +- .../index.ts} | 0 .../{ => utils}/notifications.dispatch.ts | 4 +- .../{ => utils}/notifications.helpers.ts | 4 +- .../actions/apply-db-settings.action.ts | 108 + .../generate-passkey-context.action.ts | 11 + .../actions/get-agent-status.action.ts | 17 + .../actions/onboarding-mark-done.action.ts | 21 + .../actions/update-account.action.ts | 24 + .../components/backup-schedule-selector.tsx | 163 + .../components/db-settings/db-detail.tsx | 134 + .../components/db-settings/db-grid.tsx | 60 + .../components/db-settings/db-section.tsx | 141 + .../db-settings/notifications-section.tsx | 35 + .../db-settings/retention-section.tsx | 31 + .../db-settings/scheduling-section.tsx | 43 + .../db-settings/storage-section.tsx | 34 + .../onboarding/constants/db-settings.ts | 15 + src/features/onboarding/constants/steps.ts | 33 + .../onboarding/hooks/use-add-notifier.ts | 55 + .../onboarding/hooks/use-add-storage.ts | 56 + .../onboarding/hooks/use-agent-status.ts | 23 + .../onboarding/hooks/use-apply-db-settings.ts | 12 + .../onboarding/hooks/use-create-agent.ts | 46 + .../onboarding/hooks/use-create-org.ts | 63 + .../onboarding/hooks/use-create-project.ts | 62 + .../onboarding/hooks/use-delete-agent.ts | 25 + .../hooks/use-mark-onboarding-done.ts | 14 + .../onboarding/hooks/use-remove-notifier.ts | 32 + .../onboarding/hooks/use-remove-storage.ts | 32 + .../onboarding/hooks/use-update-account.ts | 115 + src/features/onboarding/is-onboarding-done.ts | 8 + .../onboarding/onboarding-checklist.tsx | 78 + src/features/onboarding/onboarding-shell.tsx | 124 + src/features/onboarding/onboarding-state.ts | 233 ++ .../onboarding/onboarding-stepper.tsx | 31 + src/features/onboarding/onboarding-steps.tsx | 156 + .../onboarding/schemas/account.schema.ts | 11 + .../onboarding/schemas/db-settings.schema.ts | 44 + .../onboarding/steps/step-account-info.tsx | 190 ++ .../onboarding/steps/step-agent-create.tsx | 78 + .../onboarding/steps/step-agent-key.tsx | 63 + .../onboarding/steps/step-agent-waiting.tsx | 36 + .../onboarding/steps/step-db-settings.tsx | 180 + .../onboarding/steps/step-defaults.tsx | 202 ++ src/features/onboarding/steps/step-finish.tsx | 42 + .../onboarding/steps/step-invite-members.tsx | 65 + src/features/onboarding/steps/step-login.tsx | 314 ++ .../onboarding/steps/step-notifier.tsx | 215 ++ .../onboarding/steps/step-org-create.tsx | 52 + .../onboarding/steps/step-project-create.tsx | 126 + .../onboarding/steps/step-security.tsx | 99 + .../onboarding/steps/step-storage.tsx | 217 ++ src/features/onboarding/types/index.ts | 127 + src/features/onboarding/utils/.gitkeep | 0 .../actions/add-member.action.ts | 46 + .../channels-organization.action.ts | 0 .../{ => actions}/organization.action.ts | 11 +- .../{ => actions}/role-member.action.ts | 2 +- .../{ => actions}/update-member.action.ts | 2 +- .../organizations/add-member.action.ts | 44 - .../{ => components}/admin-org-columns.tsx | 2 +- .../admin-organization-add-modal.tsx | 2 +- .../admin-organization-delete-button.tsx | 2 +- .../admin-organization-form.tsx | 2 +- .../admin-organization-list.tsx | 2 +- .../admin-organization-management.tsx | 6 +- .../admin-organization-section.tsx | 4 +- .../admin-organizations-table.tsx | 2 +- .../channels-organization-form.tsx | 6 +- .../{ => components}/member-columns.tsx | 4 +- .../organization-add-member-form.tsx | 4 +- .../organization-add-member-modal.tsx | 2 +- .../organization-agents-tab.tsx | 4 +- .../{ => components}/organization-columns.tsx | 0 .../organization-combobox.tsx | 2 +- .../organization-create-modal.tsx | 4 +- .../organization-delete-button.tsx | 2 +- .../organization-delete-member-modal.tsx | 0 .../organization-edit-dialog.tsx | 2 +- .../{ => components}/organization-form.tsx | 4 +- .../organization-member-card.tsx | 4 +- .../organization-member-change-role.tsx | 4 +- .../organization-members-table.tsx | 2 +- .../organization-notifiers-tab.tsx | 4 +- .../organization-storages-tab.tsx | 4 +- .../{ => components}/organization-tabs.tsx | 10 +- .../update-organization-form.tsx | 4 +- .../use-organization-permissions.ts | 0 .../admin-organization.schema.ts | 0 .../channels-organization.schema.ts | 0 .../{ => schemas}/member.schema.ts | 0 .../{ => schemas}/organization.schema.ts | 6 +- .../{ => utils}/organization-cookie.ts | 0 src/features/profile/actions/avatar.action.ts | 36 + .../profile/{ => actions}/profile.action.ts | 0 .../profile/{ => actions}/provider.action.ts | 0 .../profile/{ => actions}/security.action.ts | 0 src/features/profile/avatar-with-upload.tsx | 93 - src/features/profile/avatar.action.ts | 15 - .../profile/{ => components}/2fa-form.tsx | 2 +- .../profile/components/avatar-with-upload.tsx | 149 + .../{ => components}/backup-codes-list.tsx | 0 .../{ => components}/disable-2fa-modal.tsx | 0 .../{ => components}/profile-account.tsx | 2 +- .../{ => components}/profile-api-keys.tsx | 2 +- .../{ => components}/profile-appearance.tsx | 3 +- .../{ => components}/profile-general.tsx | 12 +- .../{ => components}/profile-providers.tsx | 0 .../profile-reset-password-form.tsx | 2 +- .../{ => components}/profile-security.tsx | 2 +- .../{ => components}/reset-password-modal.tsx | 0 .../{ => components}/set-password-form.tsx | 4 +- .../{ => components}/set-password-modal.tsx | 0 .../profile/components/setup-2fa-modal.tsx | 45 + .../components/two-factor-setup-content.tsx | 171 + .../view-backup-codes-modal.tsx | 0 .../profile/{ => schemas}/2fa.schema.ts | 0 .../profile/{ => schemas}/account.schema.ts | 0 .../profile/{ => schemas}/general.schema.ts | 0 .../profile/{ => schemas}/provider.schema.ts | 0 .../profile/{ => schemas}/security.schema.ts | 0 src/features/profile/setup-2fa-modal.tsx | 256 -- .../{ => actions}/project-delete.action.ts | 0 .../projects/{ => actions}/projects.action.ts | 2 +- .../{ => components}/project-card.tsx | 0 .../project-database-card.tsx | 0 .../project-delete-button.tsx | 2 +- .../{ => components}/project-dialog.tsx | 2 +- .../{ => components}/project-form.tsx | 5 +- .../projects/{ => schemas}/projects.schema.ts | 0 .../{ => actions}/email-form.action.ts | 2 +- .../{ => actions}/notification.action.ts | 2 +- .../{ => actions}/storage-s3.action.ts | 2 +- .../settings/{ => actions}/storage.action.ts | 2 +- .../settings/{ => components}/email-form.tsx | 13 +- .../{ => components}/email-section.tsx | 4 +- .../{ => components}/notification-section.tsx | 6 +- .../settings/components/settings-tabs.tsx | 76 + .../{ => components}/storage-s3-form.tsx | 4 +- .../{ => components}/storage-section.tsx | 8 +- .../{ => schemas}/email-form.schema.ts | 0 .../{ => schemas}/notification.schema.ts | 0 .../{ => schemas}/storage-s3.schema.ts | 0 .../settings/{ => schemas}/storage.schema.ts | 0 src/features/settings/settings-tabs.tsx | 92 - .../{ => components}/chart-placeholder.tsx | 0 .../{ => components}/evolution-line-chart.tsx | 4 +- .../{ => components}/line-chart.tsx | 2 +- .../percentage-line-chart.tsx | 2 +- .../statistics/{ => utils}/fake-data.ts | 0 .../{storages.types.ts => types/index.ts} | 0 .../storages/{ => utils}/storages.dispatch.ts | 6 +- .../storages/{ => utils}/storages.helpers.ts | 4 +- .../theme/{ => components}/mode-toggle.tsx | 0 .../theme-meta-updater-root.tsx | 0 .../{ => components}/theme-meta-updater.tsx | 5 +- .../theme/{ => components}/theme-provider.tsx | 0 .../{ => components}/update-notification.tsx | 2 +- .../updates/{ => hooks}/use-update-check.ts | 0 src/features/upload/actions/upload.action.ts | 105 + src/features/upload/upload.action.ts | 104 - .../users/{ => actions}/user.action.ts | 4 +- src/features/users/admin-user-list.tsx | 13 - .../{ => components}/admin-user-add-modal.tsx | 2 +- .../admin-user-change-password-modal.tsx | 0 .../admin-user-change-role-modal.tsx | 0 .../admin-user-delete-modal.tsx | 2 +- .../{ => components}/admin-user-edit-form.tsx | 4 +- .../admin-user-edit-modal.tsx | 2 +- .../{ => components}/admin-user-form.tsx | 4 +- .../users/components/admin-user-list.tsx | 14 + .../{ => components}/user-actions-cell.tsx | 8 +- .../users/{ => components}/user-columns.tsx | 7 +- .../users/{ => schemas}/user.schema.ts | 0 src/lib/api-v1/openapi/routes/agents.ts | 2 +- src/lib/auth/auth.ts | 1478 ++++---- src/lib/auth/current-user.ts | 16 +- src/lib/auth/passkey-context.ts | 28 + src/lib/auth/permissions.ts | 1 - src/lib/email/helpers.ts | 26 +- src/lib/email/index.ts | 71 +- src/lib/email/types.ts | 22 +- src/lib/github-releases.ts | 228 +- src/lib/logger.ts | 70 +- src/lib/mcp/http-client.ts | 11 +- src/lib/mcp/server.ts | 2 +- src/lib/mcp/tools/agents.ts | 42 +- src/lib/mcp/tools/backups.ts | 21 +- src/lib/mcp/tools/databases.ts | 79 +- src/lib/mcp/tools/response.ts | 16 +- src/lib/safe-actions/actions.ts | 36 +- src/lib/tasks/database/index.ts | 89 +- src/lib/tasks/database/retention-days.ts | 74 +- src/lib/tasks/database/retention-gsf.ts | 155 +- src/lib/tasks/database/utils/delete.ts | 185 +- src/lib/twx.tsx | 2 +- src/lib/utils.ts | 6 +- src/lib/zod.ts | 33 +- src/middleware/errorHandler.ts | 15 +- src/middleware/loggingMiddleware.ts | 24 +- src/types/action-type.ts | 43 +- src/types/auth.ts | 27 +- src/types/common.ts | 2 +- src/types/next.ts | 8 +- src/utils/common.ts | 154 +- src/utils/edge_key.ts | 2 +- src/utils/init/index.ts | 2 + src/utils/init/onboarding.ts | 18 + src/utils/init/setting.ts | 120 +- src/utils/init/user.ts | 109 +- src/utils/mock-data.ts | 115 - src/utils/name-from-email.ts | 16 +- src/utils/os-parser.ts | 148 - src/utils/resolve-avatar-url.ts | 12 + src/utils/rsa-keys.ts | 88 +- src/utils/slugify.ts | 26 +- src/utils/text.ts | 84 +- src/utils/verify-uuid.ts | 5 - 451 files changed, 15904 insertions(+), 5054 deletions(-) create mode 100644 app/(welcome)/welcome/onboarding-client.tsx create mode 100644 app/(welcome)/welcome/page.tsx delete mode 100644 src/components/common/back-button.tsx create mode 100644 src/components/common/theme-selector.tsx create mode 100644 src/db/migrations/0065_mighty_killer_shrike.sql create mode 100644 src/db/migrations/0066_milky_puma.sql create mode 100644 src/db/migrations/meta/0065_snapshot.json create mode 100644 src/db/migrations/meta/0066_snapshot.json create mode 100644 src/db/services/organization.ts create mode 100644 src/db/services/setting.ts rename src/features/agents/{ => actions}/agent-delete.action.ts (100%) rename src/features/agents/{ => actions}/agent-organizations.action.ts (100%) rename src/features/agents/{ => actions}/agents.action.ts (97%) rename src/features/agents/{ => actions}/keys.action.ts (100%) rename src/features/agents/{ => components}/agent-card-key.tsx (100%) rename src/features/agents/{ => components}/agent-card.tsx (98%) rename src/features/agents/{ => components}/agent-content.tsx (94%) rename src/features/agents/{ => components}/agent-database-card.tsx (100%) rename src/features/agents/{ => components}/agent-database-columns.tsx (100%) rename src/features/agents/{ => components}/agent-delete-button.tsx (96%) rename src/features/agents/{ => components}/agent-dialog.tsx (96%) rename src/features/agents/{ => components}/agent-form.tsx (97%) rename src/features/agents/{ => components}/agent-key-modal.tsx (100%) rename src/features/agents/{ => components}/agent-organizations.form.tsx (97%) rename src/features/agents/{ => hooks}/use-agent-update-check.ts (100%) rename src/features/agents/{ => schemas}/agent-organizations.schema.ts (100%) rename src/features/agents/{ => schemas}/agents.schema.ts (100%) rename src/features/auth/{ => actions}/forgot-password.action.ts (100%) rename src/features/auth/{ => actions}/reset-password.action.ts (100%) rename src/features/auth/{ => components}/auth-logo-section.tsx (100%) rename src/features/auth/{ => components}/forgot-password-form.tsx (96%) rename src/features/auth/{ => components}/guard-form.tsx (90%) rename src/features/auth/{ => components}/login-form.tsx (98%) rename src/features/auth/{ => components}/register-form.tsx (97%) rename src/features/auth/{ => components}/reset-password-form.tsx (97%) create mode 100644 src/features/auth/components/social-buttons.tsx rename src/features/auth/{ => schemas}/forgot-password-form.schema.ts (100%) rename src/features/auth/{ => schemas}/login-form.schema.ts (100%) rename src/features/auth/{ => schemas}/register-form.schema.ts (100%) rename src/features/auth/{ => schemas}/reset-password-form.schema.ts (100%) rename src/features/auth/{ => schemas}/reset-password-page.schema.ts (100%) delete mode 100644 src/features/auth/social-buttons.tsx delete mode 100644 src/features/channel/channel-card.tsx rename src/features/channel/{ => components}/channel-add-edit-modal.tsx (97%) create mode 100644 src/features/channel/components/channel-card.tsx rename src/features/channel/{ => components}/channel-delete-button.tsx (93%) rename src/features/channel/{ => components}/channel-edit-button.tsx (91%) rename src/features/channel/{ => components}/channel-form.tsx (95%) rename src/features/channel/{ => components}/channel-test-button.tsx (89%) rename src/features/channel/{ => components}/channels-helpers.tsx (73%) rename src/features/channel/{ => components}/channels-notification-helper.tsx (99%) rename src/features/channel/{ => components}/channels-section.tsx (89%) rename src/features/channel/{ => components}/channels-storage-helper.tsx (98%) create mode 100644 src/features/channel/components/notifications/channel.action.ts rename src/features/channel/{ => components}/notifications/discord.form.tsx (100%) rename src/features/channel/{ => components}/notifications/discord.schema.ts (100%) rename src/features/channel/{ => components}/notifications/discord.ts (97%) rename src/features/channel/{ => components}/notifications/gotify.form.tsx (100%) rename src/features/channel/{ => components}/notifications/gotify.schema.ts (100%) rename src/features/channel/{ => components}/notifications/gotify.ts (97%) rename src/features/channel/{ => components}/notifications/index.ts (62%) rename src/features/channel/{ => components}/notifications/nextcloud.form.tsx (100%) rename src/features/channel/{ => components}/notifications/nextcloud.schema.ts (100%) rename src/features/channel/{ => components}/notifications/nextcloud.ts (98%) rename src/features/channel/{ => components}/notifications/ntfy.form.tsx (100%) rename src/features/channel/{ => components}/notifications/ntfy.schema.ts (100%) rename src/features/channel/{ => components}/notifications/ntfy.ts (98%) rename src/features/channel/{ => components}/notifications/pushover.form.tsx (97%) rename src/features/channel/{ => components}/notifications/pushover.schema.ts (100%) rename src/features/channel/{ => components}/notifications/pushover.ts (98%) rename src/features/channel/{ => components}/notifications/slack.form.tsx (100%) rename src/features/channel/{ => components}/notifications/slack.schema.ts (100%) rename src/features/channel/{ => components}/notifications/slack.ts (97%) rename src/features/channel/{ => components}/notifications/smtp.form.tsx (100%) rename src/features/channel/{ => components}/notifications/smtp.schema.ts (100%) rename src/features/channel/{ => components}/notifications/smtp.ts (97%) rename src/features/channel/{ => components}/notifications/teams.form.tsx (100%) rename src/features/channel/{ => components}/notifications/teams.schema.ts (100%) rename src/features/channel/{ => components}/notifications/teams.ts (98%) rename src/features/channel/{ => components}/notifications/telegram.form.tsx (100%) rename src/features/channel/{ => components}/notifications/telegram.schema.ts (100%) rename src/features/channel/{ => components}/notifications/telegram.ts (97%) rename src/features/channel/{ => components}/notifications/webhook.form.tsx (100%) rename src/features/channel/{ => components}/notifications/webhook.schema.ts (100%) rename src/features/channel/{ => components}/notifications/webhook.ts (97%) rename src/features/channel/{ => components}/storages/az-blob.form.tsx (100%) rename src/features/channel/{ => components}/storages/az-blob.schema.ts (100%) rename src/features/channel/{ => components}/storages/az-blob.ts (99%) create mode 100644 src/features/channel/components/storages/channel.action.ts rename src/features/channel/{ => components}/storages/google-drive/google-drive-refresh.action.ts (100%) rename src/features/channel/{ => components}/storages/google-drive/google-drive.form.tsx (98%) rename src/features/channel/{ => components}/storages/google-drive/google-drive.schema.ts (100%) rename src/features/channel/{ => components}/storages/google-drive/helpers.ts (97%) rename src/features/channel/{ => components}/storages/google-drive/index.ts (95%) rename src/features/channel/{ => components}/storages/google-drive/types.ts (100%) rename src/features/channel/{ => components}/storages/index.ts (92%) rename src/features/channel/{ => components}/storages/local.schema.ts (100%) rename src/features/channel/{ => components}/storages/local.ts (97%) rename src/features/channel/{ => components}/storages/s3.form.tsx (100%) rename src/features/channel/{ => components}/storages/s3.schema.ts (100%) rename src/features/channel/{ => components}/storages/s3.ts (99%) delete mode 100644 src/features/channel/notifications/channel.action.ts rename src/features/channel/{ => schemas}/channel-form.schema.ts (64%) delete mode 100644 src/features/channel/storages/channel.action.ts rename src/features/database/{ => actions}/backup-actions.action.ts (98%) rename src/features/database/{ => actions}/backup-button.action.ts (100%) rename src/features/database/{ => actions}/backup-get-data.action.ts (100%) rename src/features/database/{ => actions}/channels-policy.action.ts (99%) rename src/features/database/{ => actions}/cron.action.ts (100%) rename src/features/database/{ => actions}/database-form.action.ts (88%) rename src/features/database/{ => actions}/import.action.ts (97%) rename src/features/database/{ => actions}/restore.action.ts (100%) rename src/features/database/{ => actions}/retention-policy.action.ts (97%) delete mode 100644 src/features/database/channels-policy-modal.tsx rename src/features/database/{ => components}/backup-actions-cell.tsx (97%) rename src/features/database/{ => components}/backup-actions-form.tsx (96%) rename src/features/database/{ => components}/backup-actions-modal.tsx (88%) rename src/features/database/{ => components}/backup-button.tsx (95%) rename src/features/database/{ => components}/backup-columns.tsx (94%) rename src/features/database/{ => components}/backup-modal-context.tsx (100%) rename src/features/database/{ => components}/channels-policy-form.tsx (55%) create mode 100644 src/features/database/components/channels-policy-modal.tsx rename src/features/database/{ => components}/cron-advanced-select.tsx (100%) rename src/features/database/{ => components}/cron-button.tsx (96%) rename src/features/database/{ => components}/cron-input.tsx (97%) rename src/features/database/{ => components}/database-backup-list.tsx (97%) rename src/features/database/{ => components}/database-content.tsx (86%) rename src/features/database/{ => components}/database-form.tsx (97%) rename src/features/database/{ => components}/database-kpi.tsx (100%) rename src/features/database/{ => components}/database-restore-list.tsx (96%) rename src/features/database/{ => components}/database-tabs.tsx (94%) rename src/features/database/{ => components}/health-grid.tsx (100%) rename src/features/database/{ => components}/health-modal.tsx (94%) rename src/features/database/{ => components}/import-modal.tsx (94%) rename src/features/database/{ => components}/import-upload-zone.tsx (98%) rename src/features/database/{ => components}/restore-columns.tsx (98%) create mode 100644 src/features/database/components/retention-policy-form.tsx create mode 100644 src/features/database/components/retention-policy-sheet.tsx rename src/features/database/{ => components}/retention-policy.tsx (99%) delete mode 100644 src/features/database/retention-policy-form.tsx delete mode 100644 src/features/database/retention-policy-sheet.tsx rename src/features/database/{ => schemas}/backup-actions.schema.ts (100%) rename src/features/database/{ => schemas}/channels-policy.schema.ts (100%) rename src/features/database/{ => schemas}/database-form.schema.ts (100%) rename src/features/database/{ => schemas}/retention-policy.schema.ts (100%) rename src/features/layout/{ => components}/app-sidebar.tsx (73%) rename src/features/layout/{ => components}/card-auth.tsx (100%) rename src/features/layout/{ => components}/demo-reset-banner.tsx (100%) rename src/features/layout/{ => components}/header.tsx (86%) rename src/features/layout/{ => components}/logged-in-button.server.tsx (70%) rename src/features/layout/{ => components}/logged-in-button.tsx (84%) rename src/features/layout/{ => components}/logged-in-dropdown.tsx (94%) rename src/features/layout/{ => components}/logo-sidebar.tsx (100%) rename src/features/layout/{ => components}/menu-sidebar-main.tsx (99%) rename src/features/layout/{ => components}/menu-sidebar.tsx (100%) rename src/features/layout/{ => components}/page.tsx (100%) rename src/features/layout/{ => components}/profile-modal.tsx (83%) rename src/features/layout/{ => components}/profile-sidebar.tsx (100%) rename src/features/layout/{ => components}/side-bar-footer-credit.tsx (100%) rename src/features/logs/{ => components}/log-row-modal.tsx (100%) rename src/features/logs/{ => components}/logs-modal-context.tsx (100%) rename src/features/logs/{ => components}/logs-modal-trigger.tsx (86%) rename src/features/logs/{ => components}/logs-modal.tsx (91%) rename src/features/logs/{types.ts => types/index.ts} (100%) rename src/features/migration/{ => actions}/migration.action.ts (98%) rename src/features/migration/{ => components}/migration-flow.tsx (100%) rename src/features/migration/{ => components}/migration-tool.tsx (95%) rename src/features/migration/{ => components}/source-panel.tsx (100%) rename src/features/migration/{ => components}/target-panel.tsx (100%) rename src/features/notifications/{ => components}/notification-log-columns.tsx (95%) rename src/features/notifications/{ => components}/notification-log-modal.tsx (100%) rename src/features/notifications/{ => components}/notification-logs-list.tsx (88%) rename src/features/notifications/{notifications.types.ts => types/index.ts} (100%) rename src/features/notifications/{ => utils}/notifications.dispatch.ts (97%) rename src/features/notifications/{ => utils}/notifications.helpers.ts (96%) create mode 100644 src/features/onboarding/actions/apply-db-settings.action.ts create mode 100644 src/features/onboarding/actions/generate-passkey-context.action.ts create mode 100644 src/features/onboarding/actions/get-agent-status.action.ts create mode 100644 src/features/onboarding/actions/onboarding-mark-done.action.ts create mode 100644 src/features/onboarding/actions/update-account.action.ts create mode 100644 src/features/onboarding/components/backup-schedule-selector.tsx create mode 100644 src/features/onboarding/components/db-settings/db-detail.tsx create mode 100644 src/features/onboarding/components/db-settings/db-grid.tsx create mode 100644 src/features/onboarding/components/db-settings/db-section.tsx create mode 100644 src/features/onboarding/components/db-settings/notifications-section.tsx create mode 100644 src/features/onboarding/components/db-settings/retention-section.tsx create mode 100644 src/features/onboarding/components/db-settings/scheduling-section.tsx create mode 100644 src/features/onboarding/components/db-settings/storage-section.tsx create mode 100644 src/features/onboarding/constants/db-settings.ts create mode 100644 src/features/onboarding/constants/steps.ts create mode 100644 src/features/onboarding/hooks/use-add-notifier.ts create mode 100644 src/features/onboarding/hooks/use-add-storage.ts create mode 100644 src/features/onboarding/hooks/use-agent-status.ts create mode 100644 src/features/onboarding/hooks/use-apply-db-settings.ts create mode 100644 src/features/onboarding/hooks/use-create-agent.ts create mode 100644 src/features/onboarding/hooks/use-create-org.ts create mode 100644 src/features/onboarding/hooks/use-create-project.ts create mode 100644 src/features/onboarding/hooks/use-delete-agent.ts create mode 100644 src/features/onboarding/hooks/use-mark-onboarding-done.ts create mode 100644 src/features/onboarding/hooks/use-remove-notifier.ts create mode 100644 src/features/onboarding/hooks/use-remove-storage.ts create mode 100644 src/features/onboarding/hooks/use-update-account.ts create mode 100644 src/features/onboarding/is-onboarding-done.ts create mode 100644 src/features/onboarding/onboarding-checklist.tsx create mode 100644 src/features/onboarding/onboarding-shell.tsx create mode 100644 src/features/onboarding/onboarding-state.ts create mode 100644 src/features/onboarding/onboarding-stepper.tsx create mode 100644 src/features/onboarding/onboarding-steps.tsx create mode 100644 src/features/onboarding/schemas/account.schema.ts create mode 100644 src/features/onboarding/schemas/db-settings.schema.ts create mode 100644 src/features/onboarding/steps/step-account-info.tsx create mode 100644 src/features/onboarding/steps/step-agent-create.tsx create mode 100644 src/features/onboarding/steps/step-agent-key.tsx create mode 100644 src/features/onboarding/steps/step-agent-waiting.tsx create mode 100644 src/features/onboarding/steps/step-db-settings.tsx create mode 100644 src/features/onboarding/steps/step-defaults.tsx create mode 100644 src/features/onboarding/steps/step-finish.tsx create mode 100644 src/features/onboarding/steps/step-invite-members.tsx create mode 100644 src/features/onboarding/steps/step-login.tsx create mode 100644 src/features/onboarding/steps/step-notifier.tsx create mode 100644 src/features/onboarding/steps/step-org-create.tsx create mode 100644 src/features/onboarding/steps/step-project-create.tsx create mode 100644 src/features/onboarding/steps/step-security.tsx create mode 100644 src/features/onboarding/steps/step-storage.tsx create mode 100644 src/features/onboarding/types/index.ts create mode 100644 src/features/onboarding/utils/.gitkeep create mode 100644 src/features/organizations/actions/add-member.action.ts rename src/features/organizations/{ => actions}/channels-organization.action.ts (100%) rename src/features/organizations/{ => actions}/organization.action.ts (93%) rename src/features/organizations/{ => actions}/role-member.action.ts (95%) rename src/features/organizations/{ => actions}/update-member.action.ts (94%) delete mode 100644 src/features/organizations/add-member.action.ts rename src/features/organizations/{ => components}/admin-org-columns.tsx (97%) rename src/features/organizations/{ => components}/admin-organization-add-modal.tsx (97%) rename src/features/organizations/{ => components}/admin-organization-delete-button.tsx (98%) rename src/features/organizations/{ => components}/admin-organization-form.tsx (96%) rename src/features/organizations/{ => components}/admin-organization-list.tsx (93%) rename src/features/organizations/{ => components}/admin-organization-management.tsx (96%) rename src/features/organizations/{ => components}/admin-organization-section.tsx (91%) rename src/features/organizations/{ => components}/admin-organizations-table.tsx (90%) rename src/features/organizations/{ => components}/channels-organization-form.tsx (94%) rename src/features/organizations/{ => components}/member-columns.tsx (95%) rename src/features/organizations/{ => components}/organization-add-member-form.tsx (95%) rename src/features/organizations/{ => components}/organization-add-member-modal.tsx (95%) rename src/features/organizations/{ => components}/organization-agents-tab.tsx (91%) rename src/features/organizations/{ => components}/organization-columns.tsx (100%) rename src/features/organizations/{ => components}/organization-combobox.tsx (99%) rename src/features/organizations/{ => components}/organization-create-modal.tsx (97%) rename src/features/organizations/{ => components}/organization-delete-button.tsx (98%) rename src/features/organizations/{ => components}/organization-delete-member-modal.tsx (100%) rename src/features/organizations/{ => components}/organization-edit-dialog.tsx (95%) rename src/features/organizations/{ => components}/organization-form.tsx (97%) rename src/features/organizations/{ => components}/organization-member-card.tsx (96%) rename src/features/organizations/{ => components}/organization-member-change-role.tsx (96%) rename src/features/organizations/{ => components}/organization-members-table.tsx (92%) rename src/features/organizations/{ => components}/organization-notifiers-tab.tsx (93%) rename src/features/organizations/{ => components}/organization-storages-tab.tsx (93%) rename src/features/organizations/{ => components}/organization-tabs.tsx (92%) rename src/features/organizations/{ => components}/update-organization-form.tsx (96%) rename src/features/organizations/{ => hooks}/use-organization-permissions.ts (100%) rename src/features/organizations/{ => schemas}/admin-organization.schema.ts (100%) rename src/features/organizations/{ => schemas}/channels-organization.schema.ts (100%) rename src/features/organizations/{ => schemas}/member.schema.ts (100%) rename src/features/organizations/{ => schemas}/organization.schema.ts (77%) rename src/features/organizations/{ => utils}/organization-cookie.ts (100%) create mode 100644 src/features/profile/actions/avatar.action.ts rename src/features/profile/{ => actions}/profile.action.ts (100%) rename src/features/profile/{ => actions}/provider.action.ts (100%) rename src/features/profile/{ => actions}/security.action.ts (100%) delete mode 100644 src/features/profile/avatar-with-upload.tsx delete mode 100644 src/features/profile/avatar.action.ts rename src/features/profile/{ => components}/2fa-form.tsx (98%) create mode 100644 src/features/profile/components/avatar-with-upload.tsx rename src/features/profile/{ => components}/backup-codes-list.tsx (100%) rename src/features/profile/{ => components}/disable-2fa-modal.tsx (100%) rename src/features/profile/{ => components}/profile-account.tsx (99%) rename src/features/profile/{ => components}/profile-api-keys.tsx (99%) rename src/features/profile/{ => components}/profile-appearance.tsx (97%) rename src/features/profile/{ => components}/profile-general.tsx (91%) rename src/features/profile/{ => components}/profile-providers.tsx (100%) rename src/features/profile/{ => components}/profile-reset-password-form.tsx (99%) rename src/features/profile/{ => components}/profile-security.tsx (99%) rename src/features/profile/{ => components}/reset-password-modal.tsx (100%) rename src/features/profile/{ => components}/set-password-form.tsx (96%) rename src/features/profile/{ => components}/set-password-modal.tsx (100%) create mode 100644 src/features/profile/components/setup-2fa-modal.tsx create mode 100644 src/features/profile/components/two-factor-setup-content.tsx rename src/features/profile/{ => components}/view-backup-codes-modal.tsx (100%) rename src/features/profile/{ => schemas}/2fa.schema.ts (100%) rename src/features/profile/{ => schemas}/account.schema.ts (100%) rename src/features/profile/{ => schemas}/general.schema.ts (100%) rename src/features/profile/{ => schemas}/provider.schema.ts (100%) rename src/features/profile/{ => schemas}/security.schema.ts (100%) delete mode 100644 src/features/profile/setup-2fa-modal.tsx rename src/features/projects/{ => actions}/project-delete.action.ts (100%) rename src/features/projects/{ => actions}/projects.action.ts (98%) rename src/features/projects/{ => components}/project-card.tsx (100%) rename src/features/projects/{ => components}/project-database-card.tsx (100%) rename src/features/projects/{ => components}/project-delete-button.tsx (97%) rename src/features/projects/{ => components}/project-dialog.tsx (97%) rename src/features/projects/{ => components}/project-form.tsx (97%) rename src/features/projects/{ => schemas}/projects.schema.ts (100%) rename src/features/settings/{ => actions}/email-form.action.ts (90%) rename src/features/settings/{ => actions}/notification.action.ts (94%) rename src/features/settings/{ => actions}/storage-s3.action.ts (97%) rename src/features/settings/{ => actions}/storage.action.ts (95%) rename src/features/settings/{ => components}/email-form.tsx (95%) rename src/features/settings/{ => components}/email-section.tsx (74%) rename src/features/settings/{ => components}/notification-section.tsx (97%) create mode 100644 src/features/settings/components/settings-tabs.tsx rename src/features/settings/{ => components}/storage-s3-form.tsx (96%) rename src/features/settings/{ => components}/storage-section.tsx (96%) rename src/features/settings/{ => schemas}/email-form.schema.ts (100%) rename src/features/settings/{ => schemas}/notification.schema.ts (100%) rename src/features/settings/{ => schemas}/storage-s3.schema.ts (100%) rename src/features/settings/{ => schemas}/storage.schema.ts (100%) delete mode 100644 src/features/settings/settings-tabs.tsx rename src/features/statistics/{ => components}/chart-placeholder.tsx (100%) rename src/features/statistics/{ => components}/evolution-line-chart.tsx (94%) rename src/features/statistics/{ => components}/line-chart.tsx (97%) rename src/features/statistics/{ => components}/percentage-line-chart.tsx (97%) rename src/features/statistics/{ => utils}/fake-data.ts (100%) rename src/features/storages/{storages.types.ts => types/index.ts} (100%) rename src/features/storages/{ => utils}/storages.dispatch.ts (95%) rename src/features/storages/{ => utils}/storages.helpers.ts (97%) rename src/features/theme/{ => components}/mode-toggle.tsx (100%) rename src/features/theme/{ => components}/theme-meta-updater-root.tsx (100%) rename src/features/theme/{ => components}/theme-meta-updater.tsx (88%) rename src/features/theme/{ => components}/theme-provider.tsx (100%) rename src/features/updates/{ => components}/update-notification.tsx (98%) rename src/features/updates/{ => hooks}/use-update-check.ts (100%) create mode 100644 src/features/upload/actions/upload.action.ts delete mode 100644 src/features/upload/upload.action.ts rename src/features/users/{ => actions}/user.action.ts (98%) delete mode 100644 src/features/users/admin-user-list.tsx rename src/features/users/{ => components}/admin-user-add-modal.tsx (94%) rename src/features/users/{ => components}/admin-user-change-password-modal.tsx (100%) rename src/features/users/{ => components}/admin-user-change-role-modal.tsx (100%) rename src/features/users/{ => components}/admin-user-delete-modal.tsx (98%) rename src/features/users/{ => components}/admin-user-edit-form.tsx (96%) rename src/features/users/{ => components}/admin-user-edit-modal.tsx (93%) rename src/features/users/{ => components}/admin-user-form.tsx (95%) create mode 100644 src/features/users/components/admin-user-list.tsx rename src/features/users/{ => components}/user-actions-cell.tsx (90%) rename src/features/users/{ => components}/user-columns.tsx (92%) rename src/features/users/{ => schemas}/user.schema.ts (100%) create mode 100644 src/lib/auth/passkey-context.ts create mode 100644 src/utils/init/onboarding.ts delete mode 100644 src/utils/mock-data.ts delete mode 100644 src/utils/os-parser.ts create mode 100644 src/utils/resolve-avatar-url.ts delete mode 100644 src/utils/verify-uuid.ts diff --git a/.env.example b/.env.example index a8e3e759..41acc702 100644 --- a/.env.example +++ b/.env.example @@ -81,3 +81,6 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost: # Default to false #TUSD_BEHIND_PROXY=true + +#Skip the onboarding on fresh instance +#SKIP_ONBOARDING=true diff --git a/.eslintrc.json b/.eslintrc.json index bab62ad2..4284c946 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,11 +1,7 @@ { - "extends": [ - "next/core-web-vitals", - "plugin:tailwindcss/recommended" - ], + "extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"], "rules": { "react/no-unescaped-entities": "off", "react-hooks/rules-of-hooks": "off" - } } diff --git a/.gitignore b/.gitignore index 4411c3bf..82edfbaf 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ certificates .codex /docs .worktrees +.superpowers \ No newline at end of file diff --git a/README.md b/README.md index 7d737455..a2f9fac2 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,4 @@ Distributed under the Apache License. See `LICENSE.txt` for more details. [ShadcnUI-url]: https://ui.shadcn.com/ [Docker-url]: https://www.docker.com/ + diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx index 8ee519f4..de1f0237 100644 --- a/app/(auth)/forgot-password/page.tsx +++ b/app/(auth)/forgot-password/page.tsx @@ -1,9 +1,9 @@ import { CardContent, CardHeader } from "@/components/ui/card"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { ForgotPasswordForm } from "@/features/auth/forgot-password-form"; +import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form"; import { env } from "@/env.mjs"; -import { CardAuth } from "@/features/layout/card-auth"; +import { CardAuth } from "@/features/layout/components/card-auth"; import { redirect } from "next/navigation"; export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) { diff --git a/app/(auth)/guard/page.tsx b/app/(auth)/guard/page.tsx index 5fa9bd8c..a4815a0e 100644 --- a/app/(auth)/guard/page.tsx +++ b/app/(auth)/guard/page.tsx @@ -1,9 +1,9 @@ import {CardContent, CardHeader} from "@/components/ui/card"; import {TooltipProvider} from "@/components/ui/tooltip"; -import {GuardForm} from "@/features/auth/guard-form"; +import {GuardForm} from "@/features/auth/components/guard-form"; import {cookies} from "next/headers"; import {redirect} from "next/navigation"; -import {CardAuth} from "@/features/layout/card-auth"; +import {CardAuth} from "@/features/layout/components/card-auth"; export default async function GuardPage() { diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index 73a3e88f..9da5af8e 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -1,7 +1,9 @@ import React from "react"; import { redirect } from "next/navigation"; import { currentUser } from "@/lib/auth/current-user"; -import { AuthLogoSection } from "@/features/auth/auth-logo-section"; +import { isOnboardingDone } from "@/db/services/setting"; +import { AuthLogoSection } from "@/features/auth/components/auth-logo-section"; +import { env } from "@/env.mjs"; import { Heart } from "lucide-react"; export default async function Layout({ @@ -9,6 +11,10 @@ export default async function Layout({ }: { children: React.ReactNode; }) { + if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) { + redirect("/welcome"); + } + const user = await currentUser(); if (user && !user.banned && user.role !== "pending") { diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index e225ccf2..6cf323ce 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,12 +1,12 @@ -import { LoginForm } from "@/features/auth/login-form"; +import { LoginForm } from "@/features/auth/components/login-form"; import { Metadata } from "next"; import { SUPPORTED_PROVIDERS } from "@/lib/auth/config"; -import { SocialAuthButtons } from "@/features/auth/social-buttons"; +import { SocialAuthButtons } from "@/features/auth/components/social-buttons"; import { TooltipProvider } from "@/components/ui/tooltip"; import { CardContent, CardHeader } from "@/components/ui/card"; import Link from "next/link"; import { Separator } from "@/components/ui/separator"; -import { CardAuth } from "@/features/layout/card-auth"; +import { CardAuth } from "@/features/layout/components/card-auth"; import { env } from "@/env.mjs"; export const metadata: Metadata = { diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index b97a0f66..c875086a 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,5 +1,5 @@ import { PageParams } from "@/types/next"; -import { RegisterForm } from "@/features/auth/register-form"; +import { RegisterForm } from "@/features/auth/components/register-form"; import { Metadata } from "next"; import { redirect } from "next/navigation"; import { env } from "@/env.mjs"; diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index db5e7cb6..d7f32b5e 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -1,10 +1,10 @@ import { CardContent, CardHeader } from "@/components/ui/card"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { ResetPasswordForm } from "@/features/auth/reset-password-form"; +import { ResetPasswordForm } from "@/features/auth/components/reset-password-form"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth/auth"; import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar"; -import { CardAuth } from "@/features/layout/card-auth"; +import { CardAuth } from "@/features/layout/components/card-auth"; import { env } from "@/env.mjs"; export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) { diff --git a/app/(customer)/dashboard/(admin)/admin/organizations/[organizationId]/page.tsx b/app/(customer)/dashboard/(admin)/admin/organizations/[organizationId]/page.tsx index 97d388b4..b0506b88 100644 --- a/app/(customer)/dashboard/(admin)/admin/organizations/[organizationId]/page.tsx +++ b/app/(customer)/dashboard/(admin)/admin/organizations/[organizationId]/page.tsx @@ -3,8 +3,8 @@ import {eq} from "drizzle-orm"; import {db} from "@/db"; import * as drizzleDb from "@/db"; import {PageParams} from "@/types/next"; -import {Page} from "@/features/layout/page"; -import {OrganizationManagement} from "@/features/organizations/admin-organization-management"; +import {Page} from "@/features/layout/components/page"; +import {OrganizationManagement} from "@/features/organizations/components/admin-organization-management"; import {buildOrganizationWithMembers} from "@/utils/common"; import {isUUID} from "@/utils/text"; import {user} from "@/db/schema/02_user"; diff --git a/app/(customer)/dashboard/(admin)/admin/organizations/page.tsx b/app/(customer)/dashboard/(admin)/admin/organizations/page.tsx index 6adff5d5..b9c2c942 100644 --- a/app/(customer)/dashboard/(admin)/admin/organizations/page.tsx +++ b/app/(customer)/dashboard/(admin)/admin/organizations/page.tsx @@ -1,10 +1,10 @@ import {PageParams} from "@/types/next"; -import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; import {db} from "@/db"; import { AdminOrganizationAddModal -} from "@/features/organizations/admin-organization-add-modal"; -import {AdminOrganizationList} from "@/features/organizations/admin-organization-list"; +} from "@/features/organizations/components/admin-organization-add-modal"; +import {AdminOrganizationList} from "@/features/organizations/components/admin-organization-list"; import {isNull} from "drizzle-orm"; export default async function RoutePage(props: PageParams<{}>) { diff --git a/app/(customer)/dashboard/(admin)/admin/settings/page.tsx b/app/(customer)/dashboard/(admin)/admin/settings/page.tsx index 2401de95..cd0085ec 100644 --- a/app/(customer)/dashboard/(admin)/admin/settings/page.tsx +++ b/app/(customer)/dashboard/(admin)/admin/settings/page.tsx @@ -1,9 +1,9 @@ import {PageParams} from "@/types/next"; -import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; import {db} from "@/db"; import {logger} from "@/lib/logger"; import {notFound} from "next/navigation"; -import {SettingsTabs} from "@/features/settings/settings-tabs"; +import {SettingsTabs} from "@/features/settings/components/settings-tabs"; import {desc, isNull} from "drizzle-orm"; import * as drizzleDb from "@/db"; import {StorageChannelWith} from "@/db/schema/12_storage-channel"; diff --git a/app/(customer)/dashboard/(admin)/admin/users/page.tsx b/app/(customer)/dashboard/(admin)/admin/users/page.tsx index d67be444..9c177219 100644 --- a/app/(customer)/dashboard/(admin)/admin/users/page.tsx +++ b/app/(customer)/dashboard/(admin)/admin/users/page.tsx @@ -1,21 +1,28 @@ import {PageParams} from "@/types/next"; -import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; import {db} from "@/db"; import {desc, isNull} from "drizzle-orm"; -import {AdminUserList} from "@/features/users/admin-user-list"; -import {AdminUserAddModal} from "@/features/users/admin-user-add-modal"; +import {AdminUserList} from "@/features/users/components/admin-user-list"; +import {AdminUserAddModal} from "@/features/users/components/admin-user-add-modal"; import {SUPPORTED_PROVIDERS} from "@/lib/auth/config"; +import {getSettings} from "@/db/services/setting"; +import {resolveAvatarUrl} from "@/utils/resolve-avatar-url"; export default async function RoutePage(props: PageParams<{}>) { - const users = await db.query.user.findMany({ - where: (fields) => isNull(fields.deletedAt), - with: { - accounts: true - }, - orderBy: (fields) => desc(fields.createdAt), + const [settings, users] = await Promise.all([ + getSettings(), + db.query.user.findMany({ + where: (fields) => isNull(fields.deletedAt), + with: { accounts: true }, + orderBy: (fields) => desc(fields.createdAt), + }), + ]); + + const avatarUrls = Object.fromEntries( + users.map((u) => [u.id, resolveAvatarUrl(u, settings)]) + ); - }); const organizations = await db.query.organization.findMany({ with: { members: true, @@ -38,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) { - + ); diff --git a/app/(customer)/dashboard/(admin)/agents/[agentId]/page.tsx b/app/(customer)/dashboard/(admin)/agents/[agentId]/page.tsx index 8b324e03..acb37153 100644 --- a/app/(customer)/dashboard/(admin)/agents/[agentId]/page.tsx +++ b/app/(customer)/dashboard/(admin)/agents/[agentId]/page.tsx @@ -4,17 +4,17 @@ import { PageContent, PageDescription, PageTitle, -} from "@/features/layout/page"; +} from "@/features/layout/components/page"; import { db } from "@/db"; import * as drizzleDb from "@/db"; import {eq, isNull} from "drizzle-orm"; import { notFound } from "next/navigation"; -import { ButtonDeleteAgent } from "@/features/agents/agent-delete-button"; +import { ButtonDeleteAgent } from "@/features/agents/components/agent-delete-button"; import { capitalizeFirstLetter } from "@/utils/text"; import { generateEdgeKey } from "@/utils/edge_key"; import { getServerUrl } from "@/utils/get-server-url"; -import { AgentContentPage } from "@/features/agents/agent-content"; -import { AgentDialog } from "@/features/agents/agent-dialog"; +import { AgentContentPage } from "@/features/agents/components/agent-content"; +import { AgentDialog } from "@/features/agents/components/agent-dialog"; export default async function RoutePage( diff --git a/app/(customer)/dashboard/(admin)/agents/page.tsx b/app/(customer)/dashboard/(admin)/agents/page.tsx index eab14b56..a17511db 100644 --- a/app/(customer)/dashboard/(admin)/agents/page.tsx +++ b/app/(customer)/dashboard/(admin)/agents/page.tsx @@ -1,13 +1,13 @@ import {PageParams} from "@/types/next"; -import {AgentCard} from "@/features/agents/agent-card"; +import {AgentCard} from "@/features/agents/components/agent-card"; import {CardsWithPagination} from "@/components/common/cards-with-pagination"; -import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; import {notFound} from "next/navigation"; import {db} from "@/db"; import * as drizzleDb from "@/db"; import {and, desc, eq, isNull, not} from "drizzle-orm"; import {Metadata} from "next"; -import {AgentDialog} from "@/features/agents/agent-dialog"; +import {AgentDialog} from "@/features/agents/components/agent-dialog"; export const metadata: Metadata = { title: "Agents", diff --git a/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx b/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx index 7be60b51..c0121df8 100644 --- a/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx +++ b/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx @@ -1,11 +1,11 @@ import {PageParams} from "@/types/next"; -import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; import {Metadata} from "next"; import {db} from "@/db"; import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {desc, isNull} from "drizzle-orm"; -import {ChannelsSection} from "@/features/channel/channels-section"; -import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal"; +import {ChannelsSection} from "@/features/channel/components/channels-section"; +import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal"; import * as drizzleDb from "@/db"; export const metadata: Metadata = { diff --git a/app/(customer)/dashboard/(admin)/notifications/logs/page.tsx b/app/(customer)/dashboard/(admin)/notifications/logs/page.tsx index 71331d70..ad9d7599 100644 --- a/app/(customer)/dashboard/(admin)/notifications/logs/page.tsx +++ b/app/(customer)/dashboard/(admin)/notifications/logs/page.tsx @@ -1,7 +1,7 @@ import {PageParams} from "@/types/next"; -import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; import {Metadata} from "next"; -import {NotificationLogsList} from "@/features/notifications/notification-logs-list"; +import {NotificationLogsList} from "@/features/notifications/components/notification-logs-list"; import {notFound} from "next/navigation"; import {getNotificationHistory} from "@/db/services/notification-log"; diff --git a/app/(customer)/dashboard/(admin)/storages/channels/page.tsx b/app/(customer)/dashboard/(admin)/storages/channels/page.tsx index 1ffc0d8e..3df6251e 100644 --- a/app/(customer)/dashboard/(admin)/storages/channels/page.tsx +++ b/app/(customer)/dashboard/(admin)/storages/channels/page.tsx @@ -1,12 +1,12 @@ import {PageParams} from "@/types/next"; -import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; import {Metadata} from "next"; -import {ChannelsSection} from "@/features/channel/channels-section"; +import {ChannelsSection} from "@/features/channel/components/channels-section"; import {db} from "@/db"; import {desc, eq, isNull} from "drizzle-orm"; import * as drizzleDb from "@/db"; import {StorageChannelWith} from "@/db/schema/12_storage-channel"; -import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal"; +import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal"; export const metadata: Metadata = { title: "Storage Channels", diff --git a/app/(customer)/dashboard/(organization)/migration/page.tsx b/app/(customer)/dashboard/(organization)/migration/page.tsx index c6119d4b..c0d89bd8 100644 --- a/app/(customer)/dashboard/(organization)/migration/page.tsx +++ b/app/(customer)/dashboard/(organization)/migration/page.tsx @@ -1,10 +1,10 @@ import {PageParams} from "@/types/next"; -import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; import {notFound} from "next/navigation"; import {getOrganization} from "@/lib/auth/auth"; import {Metadata} from "next"; import {db} from "@/db"; -import {MigrationTool} from "@/features/migration/migration-tool"; +import {MigrationTool} from "@/features/migration/components/migration-tool"; export const metadata: Metadata = { title: "Projects", diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx index cb8afdd5..658c7186 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -1,122 +1,137 @@ -import {PageParams} from "@/types/next"; -import {notFound, redirect} from "next/navigation"; -import {Page} from "@/features/layout/page"; -import {db} from "@/db"; -import {eq, and, inArray} from "drizzle-orm"; +import { PageParams } from "@/types/next"; +import { notFound, redirect } from "next/navigation"; +import { Page } from "@/features/layout/components/page"; +import { db } from "@/db"; +import { eq, and, inArray } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {getOrganizationProjectDatabases} from "@/db/services/project"; -import {getActiveMember, getOrganization} from "@/lib/auth/auth"; -import {BackupModalProvider} from "@/features/database/backup-modal-context"; -import {DatabaseContent} from "@/features/database/database-content"; -import {getHealthLast12hLogs} from "@/db/services/healthcheck"; -import {LogsModalProvider} from "@/features/logs/logs-modal-context"; +import { getOrganizationProjectDatabases } from "@/db/services/project"; +import { getActiveMember, getOrganization } from "@/lib/auth/auth"; +import { BackupModalProvider } from "@/features/database/components/backup-modal-context"; +import { DatabaseContent } from "@/features/database/components/database-content"; +import { getHealthLast12hLogs } from "@/db/services/healthcheck"; +import { LogsModalProvider } from "@/features/logs/components/logs-modal-context"; -export default async function RoutePage(props: PageParams<{ +export default async function RoutePage( + props: PageParams<{ projectId: string; - databaseId: string -}>) { - const {projectId, databaseId} = await props.params; + databaseId: string; + }>, +) { + const { projectId, databaseId } = await props.params; - const organization = await getOrganization({}); - const activeMember = await getActiveMember() + const organization = await getOrganization({}); + const activeMember = await getActiveMember(); - if (!organization || !activeMember) { - notFound(); - } + if (!organization || !activeMember) { + notFound(); + } - const databasesProject = await getOrganizationProjectDatabases({ - organizationSlug: organization.slug, - projectId: projectId - }) + const databasesProject = await getOrganizationProjectDatabases({ + organizationSlug: organization.slug, + projectId: projectId, + }); - const dbItem = await db.query.database.findFirst({ - where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)), + const dbItem = await db.query.database.findFirst({ + where: and( + inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), + eq(drizzleDb.schemas.database.id, databaseId), + eq(drizzleDb.schemas.database.projectId, projectId), + ), + with: { + project: true, + retentionPolicy: true, + alertPolicies: true, + storagePolicies: true, + }, + }); + + if (!dbItem) { + redirect("/dashboard/projects"); + } + + const backups = await db.query.backup.findMany({ + where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id), + with: { + restorations: true, + storages: { with: { - project: true, - retentionPolicy: true, - alertPolicies: true, - storagePolicies: true, - } - }); - - if (!dbItem) { - redirect("/dashboard/projects"); - } - - const backups = await db.query.backup.findMany({ - where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id), - with: { - restorations: true, - storages: { - with: { - storageChannel: true - } - }, - logs: true + storageChannel: true, }, - orderBy: (b, {desc}) => [desc(b.createdAt)], - }); + }, + logs: true, + }, + orderBy: (b, { desc }) => [desc(b.createdAt)], + }); - const restorations = await db.query.restoration.findMany({ - where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id), - with: { - logs: true - }, - orderBy: (r, {desc}) => [desc(r.createdAt)], - }); + const restorations = await db.query.restoration.findMany({ + where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id), + with: { + logs: true, + }, + orderBy: (r, { desc }) => [desc(r.createdAt)], + }); - const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing"); - const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); + //const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing"); + const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); - const totalBackups = await db.select({count: drizzleDb.schemas.backup.id}) - .from(drizzleDb.schemas.backup) - .where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id)) - .then(rows => rows.length); + const totalBackups = await db + .select({ count: drizzleDb.schemas.backup.id }) + .from(drizzleDb.schemas.backup) + .where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id)) + .then((rows) => rows.length); - const availableBackups = backups.filter(b => !b.deletedAt).length; + const availableBackups = backups.filter((b) => !b.deletedAt).length; - const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id}) - .from(drizzleDb.schemas.backup) - .where(and( - eq(drizzleDb.schemas.backup.databaseId, dbItem.id), - eq(drizzleDb.schemas.backup.status, "success") - )) - .then(rows => rows.length); + const successfulBackups = await db + .select({ count: drizzleDb.schemas.backup.id }) + .from(drizzleDb.schemas.backup) + .where( + and( + eq(drizzleDb.schemas.backup.databaseId, dbItem.id), + eq(drizzleDb.schemas.backup.status, "success"), + ), + ) + .then((rows) => rows.length); + const [settings] = await db + .select() + .from(drizzleDb.schemas.setting) + .where(eq(drizzleDb.schemas.setting.name, "system")) + .limit(1); + if (!settings) { + notFound(); + } - const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); - if (!settings) { - notFound(); - } + const databaseHealthLogs = dbItem + ? await getHealthLast12hLogs({ id: dbItem.id }) + : []; - const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : [] + const successRate = + totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; + //const isMember = activeMember?.role === "member"; - const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; - - const isMember = activeMember?.role === "member"; - - return ( - - - - - - - - ); -} \ No newline at end of file + return ( + + + + + + + + ); +} diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx index 8f3b6c2c..f9027848 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx @@ -1,108 +1,115 @@ -import {PageParams} from "@/types/next"; -import {Page, PageContent, PageTitle} from "@/features/layout/page"; -import { - ButtonDeleteProject -} from "@/features/projects/project-delete-button"; -import {CardsWithPagination} from "@/components/common/cards-with-pagination"; -import {ProjectDatabaseCard} from "@/features/projects/project-database-card"; -import {notFound, redirect} from "next/navigation"; -import {db} from "@/db"; -import {eq} from "drizzle-orm"; -import {getActiveMember, getOrganization} from "@/lib/auth/auth"; +import { PageParams } from "@/types/next"; +import { Page, PageContent, PageTitle } from "@/features/layout/components/page"; +import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button"; +import { CardsWithPagination } from "@/components/common/cards-with-pagination"; +import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card"; +import { notFound, redirect } from "next/navigation"; +import { db } from "@/db"; +import { eq } from "drizzle-orm"; +import { getActiveMember, getOrganization } from "@/lib/auth/auth"; import * as drizzleDb from "@/db"; -import {capitalizeFirstLetter} from "@/utils/text"; -import {ProjectDialog} from "@/features/projects/project-dialog"; -import {ProjectWith} from "@/db/schema/06_project"; -import {isUuidv4} from "@/utils/verify-uuid"; -import {getOrganizationAvailableDatabases} from "@/db/services/database"; +import { capitalizeFirstLetter, isUUID } from "@/utils/text"; +import { ProjectDialog } from "@/features/projects/components/project-dialog"; +import { ProjectWith } from "@/db/schema/06_project"; +import { getOrganizationAvailableDatabases } from "@/db/services/database"; +export default async function RoutePage( + props: PageParams<{ + projectId: string; + }>, +) { + const { projectId } = await props.params; -export default async function RoutePage(props: PageParams<{ - projectId: string -}>) { - const { - projectId - } = await props.params; + if (!isUUID(projectId)) { + notFound(); + } - if (!isUuidv4(projectId)) { - notFound() - } + const organization = await getOrganization({}); + const activeMember = await getActiveMember(); - const organization = await getOrganization({}); - const activeMember = await getActiveMember() + if (!organization) { + notFound(); + } + const org = await db.query.organization.findFirst({ + where: eq(drizzleDb.schemas.organization.slug, organization.slug), + }); - if (!organization) { - notFound(); - } - const org = await db.query.organization.findFirst({ - where: eq(drizzleDb.schemas.organization.slug, organization.slug), - }); + if (!org) notFound(); - if (!org) notFound(); + const proj = await db.query.project.findFirst({ + where: (proj, { and, eq, not }) => + and( + eq(proj.id, projectId), + eq(proj.organizationId, org.id), + not(eq(proj.isArchived, true)), + ), + with: { + databases: true, + }, + }); - const proj = await db.query.project.findFirst({ - where: (proj, { - and, - eq, - not - }) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))), - with: { - databases: true, - }, - }); + if (!proj) { + redirect("/dashboard/projects"); + } - if (!proj) { - redirect("/dashboard/projects"); - } + const availableDatabases = await getOrganizationAvailableDatabases( + organization.id, + proj.id, + ); + const isMember = activeMember?.role === "member"; - const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id) - const isMember = activeMember?.role === "member"; - - return ( - -
- -
- {capitalizeFirstLetter(proj.name)} -
- {!isMember && ( -
-
- -
-
- -
-
- )} -
+ return ( + +
+ +
+ {capitalizeFirstLetter(proj.name)} +
+ {!isMember && ( +
+
+ +
+
+ +
- - {proj.databases.length > 0 ? ( - - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - )} - organizationSlug={organization.slug} - // @ts-ignore - cardItem={ProjectDatabaseCard} - cardsPerPage={20} - numberOfColumns={3} - pageSizeOptions={[10, 20, 50]} - extendedProps={proj} - /> - ) : ( -
-

No databases found

-

You haven’t added any databases to this project yet.

-
- )} -
- - ); -} \ No newline at end of file + )} +
+
+ + {proj.databases.length > 0 ? ( + + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime(), + )} + organizationSlug={organization.slug} + // @ts-ignore + cardItem={ProjectDatabaseCard} + cardsPerPage={20} + numberOfColumns={3} + pageSizeOptions={[10, 20, 50]} + extendedProps={proj} + /> + ) : ( +
+

No databases found

+

+ You haven’t added any databases to this project yet. +

+
+ )} +
+
+ ); +} diff --git a/app/(customer)/dashboard/(organization)/projects/page.tsx b/app/(customer)/dashboard/(organization)/projects/page.tsx index ed617318..244a8266 100644 --- a/app/(customer)/dashboard/(organization)/projects/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/page.tsx @@ -1,13 +1,13 @@ import {PageParams} from "@/types/next"; import {CardsWithPagination} from "@/components/common/cards-with-pagination"; -import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; -import {ProjectCard} from "@/features/projects/project-card"; +import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; +import {ProjectCard} from "@/features/projects/components/project-card"; import {db} from "@/db"; import {notFound} from "next/navigation"; import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder"; import {Metadata} from "next"; -import {ProjectDialog} from "@/features/projects/project-dialog"; +import {ProjectDialog} from "@/features/projects/components/project-dialog"; import {DatabaseWith} from "@/db/schema/07_database"; import {getOrganizationAvailableDatabases} from "@/db/services/database"; diff --git a/app/(customer)/dashboard/(organization)/settings/agents/[agentId]/page.tsx b/app/(customer)/dashboard/(organization)/settings/agents/[agentId]/page.tsx index ddfe2207..be04a33a 100644 --- a/app/(customer)/dashboard/(organization)/settings/agents/[agentId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/settings/agents/[agentId]/page.tsx @@ -4,17 +4,17 @@ import { PageContent, PageDescription, PageTitle, -} from "@/features/layout/page"; +} from "@/features/layout/components/page"; import {db} from "@/db"; import * as drizzleDb from "@/db"; import {eq} from "drizzle-orm"; import {notFound} from "next/navigation"; -import {ButtonDeleteAgent} from "@/features/agents/agent-delete-button"; +import {ButtonDeleteAgent} from "@/features/agents/components/agent-delete-button"; import {capitalizeFirstLetter} from "@/utils/text"; import {generateEdgeKey} from "@/utils/edge_key"; import {getServerUrl} from "@/utils/get-server-url"; -import {AgentContentPage} from "@/features/agents/agent-content"; -import {AgentDialog} from "@/features/agents/agent-dialog"; +import {AgentContentPage} from "@/features/agents/components/agent-content"; +import {AgentDialog} from "@/features/agents/components/agent-dialog"; import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {currentUser} from "@/lib/auth/current-user"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; diff --git a/app/(customer)/dashboard/(organization)/settings/page.tsx b/app/(customer)/dashboard/(organization)/settings/page.tsx index c212713b..6793e997 100644 --- a/app/(customer)/dashboard/(organization)/settings/page.tsx +++ b/app/(customer)/dashboard/(organization)/settings/page.tsx @@ -4,17 +4,17 @@ import { PageContent, PageHeader, PageTitle, -} from "@/features/layout/page"; +} from "@/features/layout/components/page"; import {currentUser} from "@/lib/auth/current-user"; import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {notFound} from "next/navigation"; import {Metadata} from "next"; -import {OrganizationTabs} from "@/features/organizations/organization-tabs"; +import {OrganizationTabs} from "@/features/organizations/components/organization-tabs"; import {getOrganizationChannels} from "@/db/services/notification-channel"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; -import {DeleteOrganizationButton} from "@/features/organizations/organization-delete-button"; -import {EditOrganizationDialog} from "@/features/organizations/organization-edit-dialog"; +import {DeleteOrganizationButton} from "@/features/organizations/components/organization-delete-button"; +import {EditOrganizationDialog} from "@/features/organizations/components/organization-edit-dialog"; import {db} from "@/db"; import {isNull} from "drizzle-orm"; import * as drizzleDb from "@/db"; diff --git a/app/(customer)/dashboard/(organization)/statistics/page.tsx b/app/(customer)/dashboard/(organization)/statistics/page.tsx index c781c7e8..62316414 100644 --- a/app/(customer)/dashboard/(organization)/statistics/page.tsx +++ b/app/(customer)/dashboard/(organization)/statistics/page.tsx @@ -1,8 +1,8 @@ import {PageParams} from "@/types/next"; -import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; -import {EvolutionLineChart} from "@/features/statistics/evolution-line-chart"; -import {PercentageLineChart} from "@/features/statistics/percentage-line-chart"; +import {EvolutionLineChart} from "@/features/statistics/components/evolution-line-chart"; +import {PercentageLineChart} from "@/features/statistics/components/percentage-line-chart"; import {notFound} from "next/navigation"; import {db} from "@/db"; import {and, asc, count, eq, inArray} from "drizzle-orm"; diff --git a/app/(customer)/dashboard/home/page.tsx b/app/(customer)/dashboard/home/page.tsx index 77e256e5..ab47d8c9 100644 --- a/app/(customer)/dashboard/home/page.tsx +++ b/app/(customer)/dashboard/home/page.tsx @@ -1,5 +1,5 @@ import {PageParams} from "@/types/next"; -import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react"; import {currentUser} from "@/lib/auth/current-user"; diff --git a/app/(customer)/dashboard/layout.tsx b/app/(customer)/dashboard/layout.tsx index fcfcccf7..d2a97ead 100644 --- a/app/(customer)/dashboard/layout.tsx +++ b/app/(customer)/dashboard/layout.tsx @@ -2,21 +2,25 @@ import { ReactNode } from "react"; import { redirect } from "next/navigation"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; -import { AppSidebar } from "@/features/layout/app-sidebar"; -import { Header } from "@/features/layout/header"; +import { AppSidebar } from "@/features/layout/components/app-sidebar"; +import { Header } from "@/features/layout/components/header"; import { currentUser } from "@/lib/auth/current-user"; -import { ThemeMetaUpdater } from "@/features/theme/theme-meta-updater"; -import { ModeToggle } from "@/features/theme/mode-toggle"; -import { UpdateNotification } from "@/features/updates/update-notification"; +import { isOnboardingDone } from "@/db/services/setting"; +import { env } from "@/env.mjs"; +import { ModeToggle } from "@/features/theme/components/mode-toggle"; +import { UpdateNotification } from "@/features/updates/components/update-notification"; export default async function Layout({ children }: { children: ReactNode }) { + if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) { + redirect("/welcome"); + } + const user = await currentUser(); if (!user) redirect("/login"); return (
- } />
} /> diff --git a/app/(landing)/page.tsx b/app/(landing)/page.tsx index 5db3046f..80e37649 100644 --- a/app/(landing)/page.tsx +++ b/app/(landing)/page.tsx @@ -1,8 +1,14 @@ import { redirect } from "next/navigation"; -import { getCurrentOrganizationSlug } from "@/features/organizations/organization-cookie"; +import { getCurrentOrganizationSlug } from "@/features/organizations/utils/organization-cookie"; import { currentUser } from "@/lib/auth/current-user"; +import { isOnboardingDone } from "@/db/services/setting"; +import { env } from "@/env.mjs"; export default async function Index() { + if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) { + redirect("/welcome"); + } + const user = await currentUser(); if (user) { const currentOrganizationSlug = await getCurrentOrganizationSlug(); diff --git a/app/(welcome)/welcome/onboarding-client.tsx b/app/(welcome)/welcome/onboarding-client.tsx new file mode 100644 index 00000000..a202e676 --- /dev/null +++ b/app/(welcome)/welcome/onboarding-client.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { OnboardingProvider } from "@onboardjs/react"; +import { onboardingSteps } from "@/features/onboarding/onboarding-steps"; +import { OnboardingShell } from "@/features/onboarding/onboarding-shell"; +import type { OnboardingFlowData } from "@/features/onboarding/types"; + +type Props = { + initialStepId: string; + initialFlowData: Partial; +}; + +export const OnboardingClient = ({ initialStepId, initialFlowData }: Props) => { + const router = useRouter(); + + return ( + { + router.push("/dashboard/home"); + }} + > + + + ); +}; diff --git a/app/(welcome)/welcome/page.tsx b/app/(welcome)/welcome/page.tsx new file mode 100644 index 00000000..fc50104a --- /dev/null +++ b/app/(welcome)/welcome/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import { resolveOnboardingState } from "@/features/onboarding/onboarding-state"; +import { OnboardingClient } from "./onboarding-client"; + +export default async function WelcomePage() { + const result = await resolveOnboardingState(); + + if ("redirect" in result) { + redirect(result.redirect); + } + + return ( + + ); +} diff --git a/app/api/agent/[agentId]/backup/route.ts b/app/api/agent/[agentId]/backup/route.ts index 945832b7..f8bea361 100644 --- a/app/api/agent/[agentId]/backup/route.ts +++ b/app/api/agent/[agentId]/backup/route.ts @@ -6,8 +6,8 @@ import {getDatabaseOrThrow, withAgentCheck} from "./helpers"; import {Backup} from "@/db/schema/07_database"; import {withUpdatedAt} from "@/db/utils"; import {eventEmitter} from "@/lib/event"; -import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers"; -import {EventKind} from "@/features/notifications/notifications.types"; +import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers"; +import {EventKind} from "@/features/notifications/types"; import {logger} from "@/lib/logger"; import {JobLogEntry} from "@/features/logs/types"; diff --git a/app/api/agent/[agentId]/backup/upload/init/route.ts b/app/api/agent/[agentId]/backup/upload/init/route.ts index 4f9deb47..67a6bb03 100644 --- a/app/api/agent/[agentId]/backup/upload/init/route.ts +++ b/app/api/agent/[agentId]/backup/upload/init/route.ts @@ -1,79 +1,86 @@ -import {NextResponse} from "next/server"; -import {and, eq} from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { and, eq } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {db} from "@/db"; -import {getDatabaseOrThrow, withAgentCheck} from "../../helpers"; -import {isUuidv4} from "@/utils/verify-uuid"; -import {eventEmitter} from "@/lib/event"; -import {logger} from "@/lib/logger"; +import { db } from "@/db"; +import { getDatabaseOrThrow, withAgentCheck } from "../../helpers"; +import { eventEmitter } from "@/lib/event"; +import { logger } from "@/lib/logger"; +import { isUUID } from "@/utils/text"; -const log = logger.child({module: "api/agent/backup/upload/init"}); +const log = logger.child({ module: "api/agent/backup/upload/init" }); export type Body = { - generatedId: string - storageChannelId: string - backupId: string -} -export const POST = withAgentCheck(async (request: Request, {params, agent}: { - params: Promise<{ agentId: string }>, - agent: any -}) => { + generatedId: string; + storageChannelId: string; + backupId: string; +}; +export const POST = withAgentCheck( + async ( + request: Request, + { + params, + agent, + }: { + params: Promise<{ agentId: string }>; + agent: any; + }, + ) => { try { - const body: Body = await request.json(); + const body: Body = await request.json(); - log.info({data: body}, "Body for backup upload init"); + log.info({ data: body }, "Body for backup upload init"); - const generatedId = body.generatedId; - const storageChannelId = body.storageChannelId; - const backupId = body.backupId; - - if (!generatedId || !isUuidv4(generatedId)) { - return NextResponse.json( - {error: "generatedId is not a valid UUID"}, - {status: 400} - ); - } - - const database = await getDatabaseOrThrow(generatedId); - - const backup = await db.query.backup.findFirst({ - where: and( - eq(drizzleDb.schemas.backup.id, backupId), - eq(drizzleDb.schemas.backup.databaseId, database.id), - ), - }); - - if (!backup) { - return NextResponse.json( - {error: "Unable to find the corresponding backup"}, - {status: 404} - ); - } - - const [backupStorage] = await db - .insert(drizzleDb.schemas.backupStorage) - .values({ - backupId: backup.id, - storageChannelId: storageChannelId, - status: "pending", - }) - .returning(); - - eventEmitter.emit('modification', {update: true}); + const generatedId = body.generatedId; + const storageChannelId = body.storageChannelId; + const backupId = body.backupId; + if (!generatedId || !isUUID(generatedId)) { return NextResponse.json( - { - message: "Backup storage successfully created", - backupStorage: backupStorage - }, - {status: 200} + { error: "generatedId is not a valid UUID" }, + { status: 400 }, ); + } + + const database = await getDatabaseOrThrow(generatedId); + + const backup = await db.query.backup.findFirst({ + where: and( + eq(drizzleDb.schemas.backup.id, backupId), + eq(drizzleDb.schemas.backup.databaseId, database.id), + ), + }); + + if (!backup) { + return NextResponse.json( + { error: "Unable to find the corresponding backup" }, + { status: 404 }, + ); + } + + const [backupStorage] = await db + .insert(drizzleDb.schemas.backupStorage) + .values({ + backupId: backup.id, + storageChannelId: storageChannelId, + status: "pending", + }) + .returning(); + + eventEmitter.emit("modification", { update: true }); + + return NextResponse.json( + { + message: "Backup storage successfully created", + backupStorage: backupStorage, + }, + { status: 200 }, + ); } catch (error) { - log.error({error: error}, "Error in POST for INIT backup"); - return NextResponse.json( - {error: "Internal server error"}, - {status: 500} - ); + log.error({ error: error }, "Error in POST for INIT backup"); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } -}); - + }, +); diff --git a/app/api/agent/[agentId]/restore/route.ts b/app/api/agent/[agentId]/restore/route.ts index 267a92e0..0a39c977 100644 --- a/app/api/agent/[agentId]/restore/route.ts +++ b/app/api/agent/[agentId]/restore/route.ts @@ -1,110 +1,124 @@ -import {NextResponse} from "next/server"; -import {isUuidv4} from "@/utils/verify-uuid"; +import { NextResponse } from "next/server"; import * as drizzleDb from "@/db"; -import {db as dbClient, db} from "@/db"; -import {and, eq} from "drizzle-orm"; -import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers"; -import {logger} from "@/lib/logger"; -import {withUpdatedAt} from "@/db/utils"; -import {JobLogEntry} from "@/features/logs/types"; +import { db as dbClient, db } from "@/db"; +import { and, eq } from "drizzle-orm"; +import { sendNotificationsBackupRestore } from "@/features/notifications/utils/notifications.helpers"; +import { logger } from "@/lib/logger"; +import { withUpdatedAt } from "@/db/utils"; +import { JobLogEntry } from "@/features/logs/types"; +import { isUUID } from "@/utils/text"; -const log = logger.child({module: "api/agent/restore"}); +const log = logger.child({ module: "api/agent/restore" }); export type BodyResultRestore = { - generatedId: string - status: string - logs: JobLogEntry[] - durationMs: number -} -type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success'; - + generatedId: string; + status: string; + logs: JobLogEntry[]; + durationMs: number; +}; +type RestorationStatus = "waiting" | "ongoing" | "failed" | "success"; export async function POST( - request: Request, - {params}: { params: Promise<{ agentId: string }> } + request: Request, + { params }: { params: Promise<{ agentId: string }> }, ) { + try { + const agentId = (await params).agentId; + const body: BodyResultRestore = await request.json(); - try { - - const agentId = (await params).agentId - const body: BodyResultRestore = await request.json(); - - - if (!isUuidv4(body.generatedId)) { - return NextResponse.json( - {error: "generatedId is not a valid uuid"}, - {status: 500} - ); - } - - const agent = await db.query.agent.findFirst({ - where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)), - }) - if (!agent) { - return NextResponse.json({error: "Agent not found"}, {status: 404}) - } - - const database = await db.query.database.findFirst({ - where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId), - with: { - alertPolicies: true - } - }) - - if (!database) { - return NextResponse.json({error: "Database associated with generatedId provided not found"}, {status: 404}) - } - - const restoration = await db.query.restoration.findFirst({ - where: and(eq(drizzleDb.schemas.restoration.status, "ongoing"), eq(drizzleDb.schemas.restoration.databaseId, database.id),) - }) - - if (!restoration) { - return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404}) - } - - const [restorationUpdated] = await db - .update(drizzleDb.schemas.restoration) - .set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs})) - .where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning(); - - - const logsToInsert = body.logs.map((entry) => ({ - backupId: null, - restorationId: restorationUpdated.id, - - loggedAt: new Date(entry.timestamp), - - entryType: entry.type, - level: entry.level, - - message: entry.message, - command: entry.command ?? null, - output: entry.output ?? null, - - exitCode: entry.exit_code ?? null, - durationMs: entry.duration_ms ?? null, - })); - - if (logsToInsert.length > 0) { - await dbClient - .insert(drizzleDb.schemas.jobLog) - .values(logsToInsert); - } - - await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore"); - - const response = { - status: true, - message: "Restoration successfully updated" - } - - return Response.json(response, {status: 200}) - } catch (error) { - log.error({error: error}, "Error in POST handler") - return NextResponse.json( - {error: 'Internal server error'}, - {status: 500} - ); + if (!isUUID(body.generatedId)) { + return NextResponse.json( + { error: "generatedId is not a valid uuid" }, + { status: 500 }, + ); } -} \ No newline at end of file + + const agent = await db.query.agent.findFirst({ + where: and( + eq(drizzleDb.schemas.agent.id, agentId), + eq(drizzleDb.schemas.agent.isArchived, false), + ), + }); + if (!agent) { + return NextResponse.json({ error: "Agent not found" }, { status: 404 }); + } + + const database = await db.query.database.findFirst({ + where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId), + with: { + alertPolicies: true, + }, + }); + + if (!database) { + return NextResponse.json( + { error: "Database associated with generatedId provided not found" }, + { status: 404 }, + ); + } + + const restoration = await db.query.restoration.findFirst({ + where: and( + eq(drizzleDb.schemas.restoration.status, "ongoing"), + eq(drizzleDb.schemas.restoration.databaseId, database.id), + ), + }); + + if (!restoration) { + return NextResponse.json( + { error: "Unable to fin the corresponding restoration" }, + { status: 404 }, + ); + } + + const [restorationUpdated] = await db + .update(drizzleDb.schemas.restoration) + .set( + withUpdatedAt({ + status: body.status as RestorationStatus, + durationMs: body.durationMs, + }), + ) + .where(eq(drizzleDb.schemas.restoration.id, restoration.id)) + .returning(); + + const logsToInsert = body.logs.map((entry) => ({ + backupId: null, + restorationId: restorationUpdated.id, + + loggedAt: new Date(entry.timestamp), + + entryType: entry.type, + level: entry.level, + + message: entry.message, + command: entry.command ?? null, + output: entry.output ?? null, + + exitCode: entry.exit_code ?? null, + durationMs: entry.duration_ms ?? null, + })); + + if (logsToInsert.length > 0) { + await dbClient.insert(drizzleDb.schemas.jobLog).values(logsToInsert); + } + + await sendNotificationsBackupRestore( + database, + body.status == "failed" ? "error_restore" : "success_restore", + ); + + const response = { + status: true, + message: "Restoration successfully updated", + }; + + return Response.json(response, { status: 200 }); + } catch (error) { + log.error({ error: error }, "Error in POST handler"); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/agent/[agentId]/status/helpers.ts b/app/api/agent/[agentId]/status/helpers.ts index 794bdee2..daadc71a 100644 --- a/app/api/agent/[agentId]/status/helpers.ts +++ b/app/api/agent/[agentId]/status/helpers.ts @@ -1,6 +1,5 @@ import {NextResponse} from "next/server"; import {Body} from "./route"; -import {isUuidv4} from "@/utils/verify-uuid"; import {Agent} from "@/db/schema/08_agent"; import {DatabaseWith} from "@/db/schema/07_database"; import * as drizzleDb from "@/db"; @@ -8,10 +7,11 @@ import {db, db as dbClient} from "@/db"; import {and, eq, inArray} from "drizzle-orm"; import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types"; import {withUpdatedAt} from "@/db/utils"; -import type {StorageInput} from "@/features/storages/storages.types"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; import {Setting} from "@/db/schema/01_setting"; import {logger} from "@/lib/logger"; +import {isUUID} from "@/utils/text"; +import {StorageInput} from "@/features/storages/types"; +import {dispatchStorage} from "@/features/storages/utils/storages.dispatch"; const log = logger.child({module: "api/agent/status/helpers"}); @@ -53,7 +53,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat let backupSize: number | null = null if (!existingDatabase) { - if (!isUuidv4(db.generatedId)) { + if (!isUUID(db.generatedId)) { return NextResponse.json( {error: "generatedId is not a valid uuid"}, {status: 500} diff --git a/app/api/agent/[agentId]/status/route.ts b/app/api/agent/[agentId]/status/route.ts index cf67b690..e5362d03 100644 --- a/app/api/agent/[agentId]/status/route.ts +++ b/app/api/agent/[agentId]/status/route.ts @@ -1,99 +1,107 @@ -import {NextResponse} from "next/server"; -import {handleDatabases} from "./helpers"; +import { NextResponse } from "next/server"; +import { handleDatabases } from "./helpers"; import * as drizzleDb from "@/db"; -import {db} from "@/db"; -import {EDbmsSchema} from "@/db/schema/types"; -import {and, eq} from "drizzle-orm"; -import {isUuidv4} from "@/utils/verify-uuid"; -import {withUpdatedAt} from "@/db/utils"; -import {logger} from "@/lib/logger"; - - -const log = logger.child({module: "api/agent/status/route"}); +import { db } from "@/db"; +import { EDbmsSchema } from "@/db/schema/types"; +import { and, eq } from "drizzle-orm"; +import { withUpdatedAt } from "@/db/utils"; +import { logger } from "@/lib/logger"; +import { isUUID } from "@/utils/text"; +const log = logger.child({ module: "api/agent/status/route" }); export type databaseAgent = { - name: string, - dbms: EDbmsSchema, - generatedId: string - pingStatus: boolean -} + name: string; + dbms: EDbmsSchema; + generatedId: string; + pingStatus: boolean; +}; export type Body = { - version: string, - databases: databaseAgent[] -} - + version: string; + databases: databaseAgent[]; +}; export async function POST( - request: Request, - {params}: { params: Promise<{ agentId: string }> } + request: Request, + { params }: { params: Promise<{ agentId: string }> }, ) { - try { - const agentId = (await params).agentId - log.debug(`Agent ID: ${agentId}`) - const body: Body = await request.json(); - const lastContact = new Date(); - let message: string + try { + const agentId = (await params).agentId; + log.debug(`Agent ID: ${agentId}`); + const body: Body = await request.json(); + const lastContact = new Date(); + let message: string; - if (!isUuidv4(agentId)) { - message = "agentId is not a valid uuid" - log.error({error: message}, "An error occurred") - return NextResponse.json( - {error: "agentId is not a valid uuid"}, - {status: 500} - ); - } - - const agent = await db.query.agent.findFirst({ - where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)), - }) - - if (!agent) { - message = "Agent not found" - return NextResponse.json({error: message}, {status: 404}) - } - - const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); - if (!settings) { - return NextResponse.json({error: "An error occured"}, {status: 404}) - } - - const databasesResponse = await handleDatabases(body, agent, lastContact, settings) - - await db - .update(drizzleDb.schemas.agent) - .set(withUpdatedAt({ - version: body.version, - lastContact: lastContact, - healthErrorCount: null - })) - .where(eq(drizzleDb.schemas.agent.id, agentId)); - - await db - .insert(drizzleDb.schemas.healthcheckLog) - .values({ - kind: "agent", - status: "success", - objectId: agentId, - date: lastContact - }) - - const response = { - agent: { - id: agentId, - lastContact: lastContact - }, - databases: databasesResponse - } - - return Response.json(response) - } catch (error) { - log.error({error: error}, "Error in POST handler") - return NextResponse.json( - {error: 'Internal server error'}, - {status: 500} - ); + if (!isUUID(agentId)) { + message = "agentId is not a valid uuid"; + log.error({ error: message }, "An error occurred"); + return NextResponse.json( + { error: "agentId is not a valid uuid" }, + { status: 500 }, + ); } -} + const agent = await db.query.agent.findFirst({ + where: and( + eq(drizzleDb.schemas.agent.id, agentId), + eq(drizzleDb.schemas.agent.isArchived, false), + ), + }); + + if (!agent) { + message = "Agent not found"; + return NextResponse.json({ error: message }, { status: 404 }); + } + + const [settings] = await db + .select() + .from(drizzleDb.schemas.setting) + .where(eq(drizzleDb.schemas.setting.name, "system")) + .limit(1); + if (!settings) { + return NextResponse.json({ error: "An error occured" }, { status: 404 }); + } + + const databasesResponse = await handleDatabases( + body, + agent, + lastContact, + settings, + ); + + await db + .update(drizzleDb.schemas.agent) + .set( + withUpdatedAt({ + version: body.version, + lastContact: lastContact, + healthErrorCount: null, + }), + ) + .where(eq(drizzleDb.schemas.agent.id, agentId)); + + await db.insert(drizzleDb.schemas.healthcheckLog).values({ + kind: "agent", + status: "success", + objectId: agentId, + date: lastContact, + }); + + const response = { + agent: { + id: agentId, + lastContact: lastContact, + }, + databases: databasesResponse, + }; + + return Response.json(response); + } catch (error) { + log.error({ error: error }, "Error in POST handler"); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts index 27ac28b8..f47c2094 100644 --- a/app/api/auth/[...all]/route.ts +++ b/app/api/auth/[...all]/route.ts @@ -5,31 +5,33 @@ import { headers } from "next/headers"; const authHandler = toNextJsHandler(auth.handler); -async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise { - const url = req.nextUrl; - if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) { - return null; - } - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user) { - return null; - } - // @ts-ignore - if (session.user.banned || (session.user.role as string) === "pending") { - return NextResponse.json( - { error: "Account not eligible to create API keys" }, - { status: 403 } - ); - } +async function blockApiKeyCreateForRestrictedUsers( + req: NextRequest, +): Promise { + const url = req.nextUrl; + if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) { return null; + } + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) { + return null; + } + // @ts-ignore + if (session.user.banned || (session.user.role as string) === "pending") { + return NextResponse.json( + { error: "Account not eligible to create API keys" }, + { status: 403 }, + ); + } + return null; } export async function GET(req: NextRequest) { - return authHandler.GET(req); + return authHandler.GET(req); } export async function POST(req: NextRequest) { - const guard = await blockApiKeyCreateForRestrictedUsers(req); - if (guard) return guard; - return authHandler.POST(req); + const guard = await blockApiKeyCreateForRestrictedUsers(req); + if (guard) return guard; + return authHandler.POST(req); } diff --git a/app/api/files/backups/route.ts b/app/api/files/backups/route.ts index 55b1627b..14334b62 100644 --- a/app/api/files/backups/route.ts +++ b/app/api/files/backups/route.ts @@ -1,7 +1,7 @@ import {NextResponse} from "next/server"; import path from "path"; -import type {StorageInput} from "@/features/storages/storages.types"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; +import type {StorageInput} from "@/features/storages/types"; +import {dispatchStorage} from "@/features/storages/utils/storages.dispatch"; import {Readable} from "node:stream"; import {logger} from "@/lib/logger"; diff --git a/app/api/files/images/[fileName]/route.ts b/app/api/files/images/[fileName]/route.ts index bc183122..0c91f329 100644 --- a/app/api/files/images/[fileName]/route.ts +++ b/app/api/files/images/[fileName]/route.ts @@ -1,8 +1,8 @@ import {NextResponse} from "next/server"; import {auth} from "@/lib/auth/auth"; import {headers} from "next/headers"; -import {StorageInput} from "@/features/storages/storages.types"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; +import {StorageInput} from "@/features/storages/types"; +import {dispatchStorage} from "@/features/storages/utils/storages.dispatch"; import {Readable} from "node:stream"; import {logger} from "@/lib/logger"; diff --git a/app/api/v1/agents/[id]/route.ts b/app/api/v1/agents/[id]/route.ts index e8852efb..5c477b33 100644 --- a/app/api/v1/agents/[id]/route.ts +++ b/app/api/v1/agents/[id]/route.ts @@ -3,7 +3,7 @@ import {withApiKey} from "@/lib/api-v1/middleware"; import {logger} from "@/lib/logger"; import {ApiKeyContext} from "@/lib/api-v1/types"; import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents"; -import {deleteAgentService} from "@/features/agents/agent-delete.action"; +import {deleteAgentService} from "@/features/agents/actions/agent-delete.action"; const log = logger.child({module: "api/v1/agents/[id]"}); diff --git a/app/api/v1/agents/route.ts b/app/api/v1/agents/route.ts index f266532b..0245e455 100644 --- a/app/api/v1/agents/route.ts +++ b/app/api/v1/agents/route.ts @@ -5,7 +5,7 @@ import * as drizzleDb from "@/db"; import { inArray, eq, and, or, isNull } from "drizzle-orm"; import { z } from "zod"; import { logger } from "@/lib/logger"; -import {createAgentService} from "@/features/agents/agents.action"; +import {createAgentService} from "@/features/agents/actions/agents.action"; import { ActionError } from "@/lib/safe-actions/actions"; import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents"; import {ApiKeyContext} from "@/lib/api-v1/types"; diff --git a/app/not-found.tsx b/app/not-found.tsx index fe932423..f62b46cd 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,4 +1,5 @@ -import BackButton from "@/components/common/back-button"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; export default async function NotFound() { return ( @@ -12,7 +13,9 @@ export default async function NotFound() { The content you are trying to view is not available.

- Go home +
); diff --git a/app/providers.tsx b/app/providers.tsx index 16959e25..9cf4080e 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -5,8 +5,8 @@ import { type PropsWithChildren, Suspense } from "react"; import { Toaster } from "@/components/ui/sonner"; import { ErrorLayout } from "@/components/common/error-layout"; -import { ThemeMetaUpdaterRoot } from "@/features/theme/theme-meta-updater-root"; -import { ThemeProvider } from "@/features/theme/theme-provider"; +import { ThemeMetaUpdater } from "@/features/theme/components/theme-meta-updater"; +import { ThemeProvider } from "@/features/theme/components/theme-provider"; export type ProviderProps = PropsWithChildren<{}>; const queryClient = new QueryClient(); @@ -15,7 +15,7 @@ export const Providers = (props: ProviderProps) => { return ( - + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0e626b3b..a3912b17 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,12 +1,12 @@ services: app: -# build: -# context: . -# dockerfile: docker/dockerfile/Dockerfile -# target: prod + # build: + # context: . + # dockerfile: docker/dockerfile/Dockerfile + # target: prod image: portabase/portabase:1 ports: - - '8887:80' + - "8887:80" environment: TZ: "Europe/Paris" env_file: @@ -35,7 +35,7 @@ services: - POSTGRES_USER=devuser - POSTGRES_PASSWORD=changeme healthcheck: - test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ] + test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"] interval: 10s timeout: 5s retries: 5 diff --git a/next.config.ts b/next.config.ts index 6369dacd..8b0199ab 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,102 +1,100 @@ -import type {NextConfig} from "next"; -import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config"; +import type { NextConfig } from "next"; +import { PORTABASE_DEFAULT_SETTINGS } from "./portabase.config"; const isDev = process.env.NODE_ENV === "development"; - function buildCSPHeader(): string { - const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY; + const { CSP } = PORTABASE_DEFAULT_SETTINGS.SECURITY; - const directives = [ - `default-src ${CSP.DEFAULT_SRC.join(" ")}`, - `script-src ${CSP.SCRIPT_SRC.join(" ")}`, - `style-src ${CSP.STYLE_SRC.join(" ")}`, - `img-src ${CSP.IMG_SRC.join(" ")}`, - `font-src ${CSP.FONT_SRC.join(" ")}`, - `object-src ${CSP.OBJECT_SRC.join(" ")}`, - `connect-src ${CSP.CONNECT_SRC.join(" ")}`, - `base-uri ${CSP.BASE_URI.join(" ")}`, - `form-action ${CSP.FORM_ACTION.join(" ")}`, - `frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`, - ]; + const directives = [ + `default-src ${CSP.DEFAULT_SRC.join(" ")}`, + `script-src ${CSP.SCRIPT_SRC.join(" ")}`, + `style-src ${CSP.STYLE_SRC.join(" ")}`, + `img-src ${CSP.IMG_SRC.join(" ")}`, + `font-src ${CSP.FONT_SRC.join(" ")}`, + `object-src ${CSP.OBJECT_SRC.join(" ")}`, + `connect-src ${CSP.CONNECT_SRC.join(" ")}`, + `base-uri ${CSP.BASE_URI.join(" ")}`, + `form-action ${CSP.FORM_ACTION.join(" ")}`, + `frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`, + ]; - if (CSP.BLOCK_ALL_MIXED_CONTENT) { - directives.push("block-all-mixed-content"); - } + if (CSP.BLOCK_ALL_MIXED_CONTENT) { + directives.push("block-all-mixed-content"); + } - if (CSP.UPGRADE_INSECURE_REQUESTS) { - directives.push("upgrade-insecure-requests"); - } + if (CSP.UPGRADE_INSECURE_REQUESTS) { + directives.push("upgrade-insecure-requests"); + } - return directives.join("; "); + return directives.join("; "); } function buildPermissionsPolicy(): string { - return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY) - .map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`) - .join(", "); + return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY) + .map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`) + .join(", "); } - const nextConfig: NextConfig = { - output: "standalone", - typescript: { - ignoreBuildErrors: true, + output: "standalone", + devIndicators: false, + typescript: { + ignoreBuildErrors: true, + }, + logging: { + browserToTerminal: false, + }, + experimental: { + serverActions: { + bodySizeLimit: "10gb", }, - logging: { - browserToTerminal: false, - }, - experimental: { - serverActions: { - bodySizeLimit: "10gb", - }, - proxyClientMaxBodySize: '10gb', - }, - async rewrites() { - if (!isDev) return []; + proxyClientMaxBodySize: "10gb", + }, + async rewrites() { + if (!isDev) return []; - return [ - { - source: "/tus/:path*", - destination: "http://localhost:1080/tus/:path*", - }, - ]; - }, + return [ + { + source: "/tus/:path*", + destination: "http://localhost:1080/tus/:path*", + }, + ]; + }, - async headers() { - return [ - { - source: "/(.*)", - headers: [ - { - key: "Content-Security-Policy", - value: buildCSPHeader(), - }, - { - key: "Permissions-Policy", - value: buildPermissionsPolicy(), - }, - { - key: 'X-Content-Type-Options', - value: 'nosniff', - }, - { - key: 'X-Frame-Options', - value: 'DENY', - }, - { - key: 'Referrer-Policy', - value: 'strict-origin-when-cross-origin', - }, - { - key: 'Strict-Transport-Security', - value: 'max-age=63072000; includeSubDomains; preload', - } - // ...other security headers - ], - }, - ]; - }, + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "Content-Security-Policy", + value: buildCSPHeader(), + }, + { + key: "Permissions-Policy", + value: buildPermissionsPolicy(), + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/package.json b/package.json index 89de947f..46dde80c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@better-auth/sso": "1.6.11", "@hookform/resolvers": "^5.4.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@onboardjs/react": "1.0.0-rc.5", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", @@ -61,6 +62,7 @@ "argon2": "^0.43.1", "bcrypt": "^6.0.0", "better-auth": "1.6.11", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -115,6 +117,7 @@ "@release-it/bumper": "^7.0.5", "@release-it/conventional-changelog": "^10.0.6", "@tailwindcss/postcss": "^4.3.0", + "@types/canvas-confetti": "^1.9.0", "@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/node": "^22.19.19", "@types/node-forge": "^1.3.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25975295..9b5c8166 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.3.6) + '@onboardjs/react': + specifier: 1.0.0-rc.5 + version: 1.0.0-rc.5(@onboardjs/core@1.0.0-rc.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -146,6 +149,9 @@ importers: better-auth: specifier: 1.6.11 version: 1.6.11(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0)(prisma@6.7.0(typescript@5.9.3)))(next@16.2.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(prisma@6.7.0(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + canvas-confetti: + specifier: ^1.9.4 + version: 1.9.4 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -303,6 +309,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.3.0 version: 4.3.0 + '@types/canvas-confetti': + specifier: ^1.9.0 + version: 1.9.0 '@types/eslint-plugin-tailwindcss': specifier: ^3.17.0 version: 3.17.0 @@ -2237,6 +2246,16 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@onboardjs/core@1.0.0-rc.4': + resolution: {integrity: sha512-G22h1I2JUiEyDGf06YEUT2Afg26rDZM4Ku0PAZFqmr7HVukg9651UdPxZ1/9TVvAFFmW6Qq+u8oQ75KVJu80pw==} + + '@onboardjs/react@1.0.0-rc.5': + resolution: {integrity: sha512-iqC4MjrQe4VLi3HKCkczFigkjLxdjWTYrzoI1jEi3QpKOVmDAn4bmXngazW2nfPSOItkxdQq38VO7q8J2yVMhg==} + peerDependencies: + '@onboardjs/core': '>=1.0.0-rc.1' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@opentelemetry/semantic-conventions@1.41.1': resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} @@ -3849,6 +3868,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -4656,6 +4678,9 @@ packages: caniuse-lite@1.0.30001797: resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} + canvas-confetti@1.9.4: + resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} + capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} @@ -6997,6 +7022,14 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-queue@8.1.1: + resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} + engines: {node: '>=18'} + + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + pac-proxy-agent@7.2.0: resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} engines: {node: '>= 14'} @@ -10062,6 +10095,16 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@onboardjs/core@1.0.0-rc.4': + dependencies: + p-queue: 8.1.1 + + '@onboardjs/react@1.0.0-rc.5(@onboardjs/core@1.0.0-rc.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@onboardjs/core': 1.0.0-rc.4 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@opentelemetry/semantic-conventions@1.41.1': {} '@paralleldrive/cuid2@2.3.1': @@ -11837,6 +11880,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/canvas-confetti@1.9.0': {} + '@types/cors@2.8.19': dependencies: '@types/node': 22.19.20 @@ -12793,6 +12838,8 @@ snapshots: caniuse-lite@1.0.30001797: {} + canvas-confetti@1.9.4: {} + capital-case@1.0.4: dependencies: no-case: 3.0.4 @@ -15359,6 +15406,13 @@ snapshots: dependencies: p-limit: 3.1.0 + p-queue@8.1.1: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 6.1.4 + + p-timeout@6.1.4: {} + pac-proxy-agent@7.2.0: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 @@ -16657,7 +16711,7 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.6.1(esbuild@0.25.10)(postcss@8.5.10)(webpack@5.107.2(esbuild@0.27.7)(postcss@8.5.10)): + terser-webpack-plugin@5.6.1(esbuild@0.25.10)(postcss@8.5.10)(webpack@5.107.2(esbuild@0.25.10)(postcss@8.5.10)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 @@ -17082,7 +17136,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.1(esbuild@0.25.10)(postcss@8.5.10)(webpack@5.107.2(esbuild@0.27.7)(postcss@8.5.10)) + terser-webpack-plugin: 5.6.1(esbuild@0.25.10)(postcss@8.5.10)(webpack@5.107.2(esbuild@0.25.10)(postcss@8.5.10)) watchpack: 2.5.1 webpack-sources: 3.5.0 transitivePeerDependencies: diff --git a/portabase.config.ts b/portabase.config.ts index 7b3b728a..4e756ada 100644 --- a/portabase.config.ts +++ b/portabase.config.ts @@ -46,7 +46,7 @@ export const PORTABASE_DEFAULT_SETTINGS = { "https://api.iconify.design", "https://code.iconify.design", "https://api.github.com", - + "https://api.dicebear.com", ], OBJECT_SRC: ["'none'"], BASE_URI: ["'self'"], diff --git a/proxy.ts b/proxy.ts index 32331a12..1c1442c7 100644 --- a/proxy.ts +++ b/proxy.ts @@ -4,7 +4,7 @@ import { errorHandler } from "@/middleware/errorHandler"; import { auth } from "@/lib/auth/auth"; import { headers } from "next/headers"; import { env } from "@/env.mjs"; -import {User} from "@/db/schema/02_user"; +import { User } from "@/db/schema/02_user"; export async function proxy(request: NextRequest) { const url = request.nextUrl.clone(); @@ -19,7 +19,7 @@ export async function proxy(request: NextRequest) { new URL(`/login?redirect=${redirectUrl}`, request.url), ); } - const user = session.user as User + const user = session.user as User; if (user.banned) { await auth.api.signOut({ headers: await headers() }); @@ -104,6 +104,7 @@ function checkRouteExists(pathname: string) { /^\/api\/config\/?$/, /^\/api\/health\/?$/, /^\/api\/google\/drive\/callback\/?$/, + /^\/api\/avatar\/?$/, // v1 external API /^\/api\/v1\/mcp\/?$/, /^\/api\/v1\/docs\/?$/, diff --git a/seeds/keycloak/master-realm.json b/seeds/keycloak/master-realm.json index 51be35c4..98095056 100644 --- a/seeds/keycloak/master-realm.json +++ b/seeds/keycloak/master-realm.json @@ -351,6 +351,14 @@ "clientRole" : true, "containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "attributes" : { } + } ], + "exemple-client" : [ { + "id" : "67766209-57dd-45c7-940d-ed3313473101", + "name" : "uma_protection", + "composite" : false, + "clientRole" : true, + "containerId" : "248d3eaa-466f-4b85-8903-9242959e67ba", + "attributes" : { } } ] } }, @@ -458,8 +466,8 @@ "id" : "b1cedcac-5207-4479-bc29-065ba849e414", "type" : "password", "userLabel" : "My password", - "createdDate" : 1772179391727, - "secretData" : "{\"value\":\"IXr6L3pvO2BAkn/cH3rh3v2tg1R83u2f8SWEZK2fq5E=\",\"salt\":\"XWV8/yKm7Rw/YXiwXZZUig==\",\"additionalParameters\":{}}", + "createdDate" : 1782393421785, + "secretData" : "{\"value\":\"L585pLqSeHaNW4HzOu+GzZm+VJWWBO3Df+rPs04gUGk=\",\"salt\":\"w3pmQ0hsMDJnCX+qJ3K+zQ==\",\"additionalParameters\":{}}", "credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}" } ], "disableableCredentialTypes" : [ ], @@ -467,6 +475,23 @@ "realmRoles" : [ "admin", "default-roles-master" ], "notBefore" : 0, "groups" : [ "/Admin" ] + }, { + "id" : "8c749a0c-f2ed-4bc7-9f54-4e0bcbc36626", + "username" : "service-account-exemple-client", + "emailVerified" : false, + "enabled" : true, + "createdTimestamp" : 1772190246795, + "totp" : false, + "serviceAccountClientId" : "exemple-client", + "credentials" : [ ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-master" ], + "clientRoles" : { + "exemple-client" : [ "uma_protection" ] + }, + "notBefore" : 0, + "groups" : [ ] }, { "id" : "718dfe46-dbef-41a3-9120-78aeac240431", "username" : "service-account-portabase", @@ -625,6 +650,57 @@ "nodeReRegistrationTimeout" : 0, "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], "optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ] + }, { + "id" : "248d3eaa-466f-4b85-8903-9242959e67ba", + "clientId" : "exemple-client", + "name" : "Exemple Client", + "description" : "", + "rootUrl" : "https://your-instance.portabase.io", + "adminUrl" : "https://your-instance.portabase.io", + "baseUrl" : "https://your-instance.portabase.io", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "lFZY6fXzh9uxCXwvucw9JtXGhNRKgnnj", + "redirectUris" : [ "https://your-instance.portabase.io/api/auth/sso/callback/exemple-client" ], + "webOrigins" : [ "https://your-instance.portabase.io" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : true, + "authorizationServicesEnabled" : true, + "publicClient" : false, + "frontchannelLogout" : true, + "protocol" : "openid-connect", + "attributes" : { + "realm_client" : "false", + "oidc.ciba.grant.enabled" : "false", + "client.secret.creation.time" : "1772190246", + "backchannel.logout.session.required" : "true", + "standard.token.exchange.enabled" : "false", + "post.logout.redirect.uris" : "https://your-instance.portabase.io", + "oauth2.device.authorization.grant.enabled" : "true", + "pkce.code.challenge.method" : "S256", + "backchannel.logout.revoke.offline.tokens" : "false", + "dpop.bound.access.tokens" : "false" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : true, + "nodeReRegistrationTimeout" : -1, + "defaultClientScopes" : [ "web-origins", "service_account", "acr", "profile", "roles", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ], + "authorizationSettings" : { + "allowRemoteResourceManagement" : true, + "policyEnforcementMode" : "ENFORCING", + "resources" : [ ], + "policies" : [ ], + "scopes" : [ ], + "decisionStrategy" : "UNANIMOUS" + } }, { "id" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "clientId" : "master-realm", diff --git a/src/components/common/back-button.tsx b/src/components/common/back-button.tsx deleted file mode 100644 index 65a554e3..00000000 --- a/src/components/common/back-button.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; -import React from "react"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; - -type BackButtonProps = React.ComponentProps & { - children: React.ReactNode; -}; - -export default function BackButton({ children, ...props }: BackButtonProps) { - const router = useRouter(); - - return ( - - ); -} diff --git a/src/components/common/theme-selector.tsx b/src/components/common/theme-selector.tsx new file mode 100644 index 00000000..68783d2a --- /dev/null +++ b/src/components/common/theme-selector.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +export type ThemeKey = "dark" | "light" | "system"; + +const themes: { value: ThemeKey }[] = [ + { value: "light" }, + { value: "dark" }, + { value: "system" }, +]; + +const THEME_TEXT: Record = { + dark: "Dark", + light: "Light", + system: "System", +}; + +interface ThemeSelectorProps { + value?: string; + onSelect: (value: ThemeKey) => void; + className?: string; +} + +export function ThemeSelector({ + value, + onSelect, + className, +}: ThemeSelectorProps) { + return ( +
+ {themes.map((item) => { + const isDark = item.value === "dark"; + const isSystem = item.value === "system"; + const isActive = value === item.value; + + return ( +
onSelect(item.value)} + > +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + {THEME_TEXT[item.value]} + +
+ {isActive && ( +
+ )} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/components/emails/email-notification.tsx b/src/components/emails/email-notification.tsx index 7dc3a754..b4f5456e 100644 --- a/src/components/emails/email-notification.tsx +++ b/src/components/emails/email-notification.tsx @@ -1,7 +1,7 @@ // import * as React from "react"; // import EmailLayout from "./email-layout"; // import {Text, Section, Button} from "@react-email/components"; -// import type {EventPayload} from "@/features/notifications/notifications.types"; +// import type {EventPayload} from "@/features/notifications/types"; // // export interface EmailNotificationProps { // payload: EventPayload @@ -48,7 +48,7 @@ import * as React from "react"; import EmailLayout from "./email-layout"; import {Text, Section, Button} from "@react-email/components"; -import type {EventPayload} from "@/features/notifications/notifications.types"; +import type {EventPayload} from "@/features/notifications/types"; export interface EmailNotificationProps { payload: EventPayload; diff --git a/src/db/migrations/0065_mighty_killer_shrike.sql b/src/db/migrations/0065_mighty_killer_shrike.sql new file mode 100644 index 00000000..1be5dbf5 --- /dev/null +++ b/src/db/migrations/0065_mighty_killer_shrike.sql @@ -0,0 +1 @@ +ALTER TABLE "settings" ADD COLUMN "onboarding" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/0066_milky_puma.sql b/src/db/migrations/0066_milky_puma.sql new file mode 100644 index 00000000..0fb9d2c8 --- /dev/null +++ b/src/db/migrations/0066_milky_puma.sql @@ -0,0 +1,11 @@ +-- Custom SQL migration file, put your code below! -- +UPDATE settings SET onboarding = true +WHERE onboarding = false + AND ( + EXISTS (SELECT 1 FROM organization WHERE slug <> 'default') + OR EXISTS (SELECT 1 FROM agents) + OR EXISTS (SELECT 1 FROM projects) + OR EXISTS (SELECT 1 FROM databases) + OR EXISTS (SELECT 1 FROM notification_channel) + OR EXISTS (SELECT 1 FROM storage_channel WHERE provider <> 'local') + ); \ No newline at end of file diff --git a/src/db/migrations/meta/0065_snapshot.json b/src/db/migrations/meta/0065_snapshot.json new file mode 100644 index 00000000..08516396 --- /dev/null +++ b/src/db/migrations/meta/0065_snapshot.json @@ -0,0 +1,2977 @@ +{ + "id": "dd14b484-14ae-465e-b9c0-1cde9b19e791", + "prevId": "53c89cd2-a05d-4dd5-b954-790eabf5eff7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "smtp_password": { + "name": "smtp_password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_from": { + "name": "smtp_from", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_host": { + "name": "smtp_host", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_user": { + "name": "smtp_user", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_secure": { + "name": "smtp_secure", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "default_notification_channel_id": { + "name": "default_notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "default_storage_channel_id": { + "name": "default_storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "encryption": { + "name": "encryption", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding": { + "name": "onboarding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "settings_default_notification_channel_id_notification_channel_id_fk": { + "name": "settings_default_notification_channel_id_notification_channel_id_fk", + "tableFrom": "settings", + "tableTo": "notification_channel", + "columnsFrom": [ + "default_notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "settings_default_storage_channel_id_storage_channel_id_fk": { + "name": "settings_default_storage_channel_id_storage_channel_id_fk", + "tableFrom": "settings", + "tableTo": "storage_channel", + "columnsFrom": [ + "default_storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_name_unique": { + "name": "settings_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credentialID": { + "name": "credentialID", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deviceType": { + "name": "deviceType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backedUp": { + "name": "backedUp", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "user_themes", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastConnectedAt": { + "name": "lastConnectedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastChangedPasswordAt": { + "name": "lastChangedPasswordAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backups": { + "name": "backups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "imported": { + "name": "imported", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "migrated": { + "name": "migrated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "backups_database_id_databases_id_fk": { + "name": "backups_database_id_databases_id_fk", + "tableFrom": "backups", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.databases": { + "name": "databases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_database_id": { + "name": "agent_database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dbms": { + "name": "dbms", + "type": "dbms_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backup_policy": { + "name": "backup_policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_waiting_for_backup": { + "name": "is_waiting_for_backup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "backup_to_restore": { + "name": "backup_to_restore", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_error_count": { + "name": "health_error_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_contact": { + "name": "last_contact", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "databases_agent_id_agents_id_fk": { + "name": "databases_agent_id_agents_id_fk", + "tableFrom": "databases", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "databases_project_id_projects_id_fk": { + "name": "databases_project_id_projects_id_fk", + "tableFrom": "databases", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.restorations": { + "name": "restorations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "backup_storage_id": { + "name": "backup_storage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "restorations_backup_storage_id_backup_storage_id_fk": { + "name": "restorations_backup_storage_id_backup_storage_id_fk", + "tableFrom": "restorations", + "tableTo": "backup_storage", + "columnsFrom": [ + "backup_storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restorations_backup_id_backups_id_fk": { + "name": "restorations_backup_id_backups_id_fk", + "tableFrom": "restorations", + "tableTo": "backups", + "columnsFrom": [ + "backup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restorations_database_id_databases_id_fk": { + "name": "restorations_database_id_databases_id_fk", + "tableFrom": "restorations", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "retention_policy_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "days": { + "name": "days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "gfs_daily": { + "name": "gfs_daily", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "gfs_weekly": { + "name": "gfs_weekly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 4 + }, + "gfs_monthly": { + "name": "gfs_monthly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 12 + }, + "gfs_yearly": { + "name": "gfs_yearly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "retention_policies_database_id_databases_id_fk": { + "name": "retention_policies_database_id_databases_id_fk", + "tableFrom": "retention_policies", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "health_error_count": { + "name": "health_error_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_contact": { + "name": "last_contact", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agents_organization_id_organization_id_fk": { + "name": "agents_organization_id_organization_id_fk", + "tableFrom": "agents", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_slug_unique": { + "name": "agents_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_agents": { + "name": "organization_agents", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_agents_organization_id_organization_id_fk": { + "name": "organization_agents_organization_id_organization_id_fk", + "tableFrom": "organization_agents", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_agents_agent_id_agents_id_fk": { + "name": "organization_agents_agent_id_agents_id_fk", + "tableFrom": "organization_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_agents_organization_id_agent_id_unique": { + "name": "organization_agents_organization_id_agent_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "agent_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel": { + "name": "notification_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "provider_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channel_organization_id_organization_id_fk": { + "name": "notification_channel_organization_id_organization_id_fk", + "tableFrom": "notification_channel", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_notification_channels": { + "name": "organization_notification_channels", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_notification_channels_organization_id_organization_id_fk": { + "name": "organization_notification_channels_organization_id_organization_id_fk", + "tableFrom": "organization_notification_channels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_notification_channels_notification_channel_id_notification_channel_id_fk": { + "name": "organization_notification_channels_notification_channel_id_notification_channel_id_fk", + "tableFrom": "organization_notification_channels", + "tableTo": "notification_channel", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_notification_channels_organization_id_notification_channel_id_unique": { + "name": "organization_notification_channels_organization_id_notification_channel_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "notification_channel_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_policy": { + "name": "alert_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_kind": { + "name": "event_kind", + "type": "event_kind[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_policy_notification_channel_id_notification_channel_id_fk": { + "name": "alert_policy_notification_channel_id_notification_channel_id_fk", + "tableFrom": "alert_policy", + "tableTo": "notification_channel", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_policy_database_id_databases_id_fk": { + "name": "alert_policy_database_id_databases_id_fk", + "tableFrom": "alert_policy", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_log": { + "name": "notification_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_response": { + "name": "provider_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_storage_channels": { + "name": "organization_storage_channels", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_storage_channels_organization_id_organization_id_fk": { + "name": "organization_storage_channels_organization_id_organization_id_fk", + "tableFrom": "organization_storage_channels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_storage_channels_storage_channel_id_storage_channel_id_fk": { + "name": "organization_storage_channels_storage_channel_id_storage_channel_id_fk", + "tableFrom": "organization_storage_channels", + "tableTo": "storage_channel", + "columnsFrom": [ + "storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_storage_channels_organization_id_storage_channel_id_unique": { + "name": "organization_storage_channels_organization_id_storage_channel_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "storage_channel_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_channel": { + "name": "storage_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "provider_storage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "storage_channel_organization_id_organization_id_fk": { + "name": "storage_channel_organization_id_organization_id_fk", + "tableFrom": "storage_channel", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_policy": { + "name": "storage_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "storage_policy_storage_channel_id_storage_channel_id_fk": { + "name": "storage_policy_storage_channel_id_storage_channel_id_fk", + "tableFrom": "storage_policy", + "tableTo": "storage_channel", + "columnsFrom": [ + "storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "storage_policy_database_id_databases_id_fk": { + "name": "storage_policy_database_id_databases_id_fk", + "tableFrom": "storage_policy", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backup_storage": { + "name": "backup_storage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "backup_storage_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "backup_storage_backup_id_backups_id_fk": { + "name": "backup_storage_backup_id_backups_id_fk", + "tableFrom": "backup_storage", + "tableTo": "backups", + "columnsFrom": [ + "backup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_storage_storage_channel_id_storage_channel_id_fk": { + "name": "backup_storage_storage_channel_id_storage_channel_id_fk", + "tableFrom": "backup_storage", + "tableTo": "storage_channel", + "columnsFrom": [ + "storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.healthcheck_log": { + "name": "healthcheck_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "healthcheck_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "healthcheck_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_log": { + "name": "job_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "restoration_id": { + "name": "restoration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "entry_type": { + "name": "entry_type", + "type": "job_log_entry_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "job_log_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "job_log_backup_id_backups_id_fk": { + "name": "job_log_backup_id_backups_id_fk", + "tableFrom": "job_log", + "tableTo": "backups", + "columnsFrom": [ + "backup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "job_log_restoration_id_restorations_id_fk": { + "name": "job_log_restoration_id_restorations_id_fk", + "tableFrom": "job_log", + "tableTo": "restorations", + "columnsFrom": [ + "restoration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.user_themes": { + "name": "user_themes", + "schema": "public", + "values": [ + "light", + "dark", + "system" + ] + }, + "public.retention_policy_type": { + "name": "retention_policy_type", + "schema": "public", + "values": [ + "count", + "days", + "gfs" + ] + }, + "public.provider_kind": { + "name": "provider_kind", + "schema": "public", + "values": [ + "slack", + "smtp", + "discord", + "telegram", + "gotify", + "ntfy", + "webhook", + "nextcloud", + "teams", + "pushover" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "error_backup", + "error_restore", + "success_restore", + "success_backup", + "weekly_report", + "error_health_agent", + "error_health_database" + ] + }, + "public.level": { + "name": "level", + "schema": "public", + "values": [ + "critical", + "warning", + "info" + ] + }, + "public.provider_storage_kind": { + "name": "provider_storage_kind", + "schema": "public", + "values": [ + "local", + "s3", + "google-drive", + "blob" + ] + }, + "public.backup_storage_status": { + "name": "backup_storage_status", + "schema": "public", + "values": [ + "pending", + "success", + "failed" + ] + }, + "public.healthcheck_status": { + "name": "healthcheck_status", + "schema": "public", + "values": [ + "success", + "failed" + ] + }, + "public.healthcheck_kind": { + "name": "healthcheck_kind", + "schema": "public", + "values": [ + "database", + "agent" + ] + }, + "public.job_log_entry_type": { + "name": "job_log_entry_type", + "schema": "public", + "values": [ + "log", + "command" + ] + }, + "public.job_log_level": { + "name": "job_log_level", + "schema": "public", + "values": [ + "debug", + "info", + "warn", + "error" + ] + }, + "public.dbms_status": { + "name": "dbms_status", + "schema": "public", + "values": [ + "postgresql", + "mysql", + "mariadb", + "mongodb", + "sqlite", + "redis", + "valkey", + "firebird", + "mssql" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "waiting", + "ongoing", + "failed", + "success" + ] + }, + "public.type_storage": { + "name": "type_storage", + "schema": "public", + "values": [ + "local", + "s3" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/0066_snapshot.json b/src/db/migrations/meta/0066_snapshot.json new file mode 100644 index 00000000..345ce132 --- /dev/null +++ b/src/db/migrations/meta/0066_snapshot.json @@ -0,0 +1,2977 @@ +{ + "id": "95313a40-5445-4b41-ae45-c3a21358db94", + "prevId": "dd14b484-14ae-465e-b9c0-1cde9b19e791", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "smtp_password": { + "name": "smtp_password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_from": { + "name": "smtp_from", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_host": { + "name": "smtp_host", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_user": { + "name": "smtp_user", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_secure": { + "name": "smtp_secure", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "default_notification_channel_id": { + "name": "default_notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "default_storage_channel_id": { + "name": "default_storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "encryption": { + "name": "encryption", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding": { + "name": "onboarding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "settings_default_notification_channel_id_notification_channel_id_fk": { + "name": "settings_default_notification_channel_id_notification_channel_id_fk", + "tableFrom": "settings", + "columnsFrom": [ + "default_notification_channel_id" + ], + "tableTo": "notification_channel", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "settings_default_storage_channel_id_storage_channel_id_fk": { + "name": "settings_default_storage_channel_id_storage_channel_id_fk", + "tableFrom": "settings", + "columnsFrom": [ + "default_storage_channel_id" + ], + "tableTo": "storage_channel", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_name_unique": { + "name": "settings_name_unique", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credentialID": { + "name": "credentialID", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deviceType": { + "name": "deviceType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backedUp": { + "name": "backedUp", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + "provider_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "user_themes", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastConnectedAt": { + "name": "lastConnectedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastChangedPasswordAt": { + "name": "lastChangedPasswordAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "columnsFrom": [ + "inviter_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backups": { + "name": "backups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "imported": { + "name": "imported", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "migrated": { + "name": "migrated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "backups_database_id_databases_id_fk": { + "name": "backups_database_id_databases_id_fk", + "tableFrom": "backups", + "columnsFrom": [ + "database_id" + ], + "tableTo": "databases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.databases": { + "name": "databases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_database_id": { + "name": "agent_database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dbms": { + "name": "dbms", + "type": "dbms_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backup_policy": { + "name": "backup_policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_waiting_for_backup": { + "name": "is_waiting_for_backup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "backup_to_restore": { + "name": "backup_to_restore", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_error_count": { + "name": "health_error_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_contact": { + "name": "last_contact", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "databases_agent_id_agents_id_fk": { + "name": "databases_agent_id_agents_id_fk", + "tableFrom": "databases", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "databases_project_id_projects_id_fk": { + "name": "databases_project_id_projects_id_fk", + "tableFrom": "databases", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.restorations": { + "name": "restorations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "backup_storage_id": { + "name": "backup_storage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "restorations_backup_storage_id_backup_storage_id_fk": { + "name": "restorations_backup_storage_id_backup_storage_id_fk", + "tableFrom": "restorations", + "columnsFrom": [ + "backup_storage_id" + ], + "tableTo": "backup_storage", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "restorations_backup_id_backups_id_fk": { + "name": "restorations_backup_id_backups_id_fk", + "tableFrom": "restorations", + "columnsFrom": [ + "backup_id" + ], + "tableTo": "backups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "restorations_database_id_databases_id_fk": { + "name": "restorations_database_id_databases_id_fk", + "tableFrom": "restorations", + "columnsFrom": [ + "database_id" + ], + "tableTo": "databases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "retention_policy_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "days": { + "name": "days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "gfs_daily": { + "name": "gfs_daily", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "gfs_weekly": { + "name": "gfs_weekly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 4 + }, + "gfs_monthly": { + "name": "gfs_monthly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 12 + }, + "gfs_yearly": { + "name": "gfs_yearly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "retention_policies_database_id_databases_id_fk": { + "name": "retention_policies_database_id_databases_id_fk", + "tableFrom": "retention_policies", + "columnsFrom": [ + "database_id" + ], + "tableTo": "databases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "health_error_count": { + "name": "health_error_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_contact": { + "name": "last_contact", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agents_organization_id_organization_id_fk": { + "name": "agents_organization_id_organization_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_slug_unique": { + "name": "agents_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_agents": { + "name": "organization_agents", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_agents_organization_id_organization_id_fk": { + "name": "organization_agents_organization_id_organization_id_fk", + "tableFrom": "organization_agents", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "organization_agents_agent_id_agents_id_fk": { + "name": "organization_agents_agent_id_agents_id_fk", + "tableFrom": "organization_agents", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_agents_organization_id_agent_id_unique": { + "name": "organization_agents_organization_id_agent_id_unique", + "columns": [ + "organization_id", + "agent_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel": { + "name": "notification_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "provider_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channel_organization_id_organization_id_fk": { + "name": "notification_channel_organization_id_organization_id_fk", + "tableFrom": "notification_channel", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_notification_channels": { + "name": "organization_notification_channels", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_notification_channels_organization_id_organization_id_fk": { + "name": "organization_notification_channels_organization_id_organization_id_fk", + "tableFrom": "organization_notification_channels", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "organization_notification_channels_notification_channel_id_notification_channel_id_fk": { + "name": "organization_notification_channels_notification_channel_id_notification_channel_id_fk", + "tableFrom": "organization_notification_channels", + "columnsFrom": [ + "notification_channel_id" + ], + "tableTo": "notification_channel", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_notification_channels_organization_id_notification_channel_id_unique": { + "name": "organization_notification_channels_organization_id_notification_channel_id_unique", + "columns": [ + "organization_id", + "notification_channel_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_policy": { + "name": "alert_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_kind": { + "name": "event_kind", + "type": "event_kind[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_policy_notification_channel_id_notification_channel_id_fk": { + "name": "alert_policy_notification_channel_id_notification_channel_id_fk", + "tableFrom": "alert_policy", + "columnsFrom": [ + "notification_channel_id" + ], + "tableTo": "notification_channel", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "alert_policy_database_id_databases_id_fk": { + "name": "alert_policy_database_id_databases_id_fk", + "tableFrom": "alert_policy", + "columnsFrom": [ + "database_id" + ], + "tableTo": "databases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_log": { + "name": "notification_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_response": { + "name": "provider_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_storage_channels": { + "name": "organization_storage_channels", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_storage_channels_organization_id_organization_id_fk": { + "name": "organization_storage_channels_organization_id_organization_id_fk", + "tableFrom": "organization_storage_channels", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "organization_storage_channels_storage_channel_id_storage_channel_id_fk": { + "name": "organization_storage_channels_storage_channel_id_storage_channel_id_fk", + "tableFrom": "organization_storage_channels", + "columnsFrom": [ + "storage_channel_id" + ], + "tableTo": "storage_channel", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_storage_channels_organization_id_storage_channel_id_unique": { + "name": "organization_storage_channels_organization_id_storage_channel_id_unique", + "columns": [ + "organization_id", + "storage_channel_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_channel": { + "name": "storage_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "provider_storage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "storage_channel_organization_id_organization_id_fk": { + "name": "storage_channel_organization_id_organization_id_fk", + "tableFrom": "storage_channel", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_policy": { + "name": "storage_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "storage_policy_storage_channel_id_storage_channel_id_fk": { + "name": "storage_policy_storage_channel_id_storage_channel_id_fk", + "tableFrom": "storage_policy", + "columnsFrom": [ + "storage_channel_id" + ], + "tableTo": "storage_channel", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "storage_policy_database_id_databases_id_fk": { + "name": "storage_policy_database_id_databases_id_fk", + "tableFrom": "storage_policy", + "columnsFrom": [ + "database_id" + ], + "tableTo": "databases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backup_storage": { + "name": "backup_storage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "backup_storage_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "backup_storage_backup_id_backups_id_fk": { + "name": "backup_storage_backup_id_backups_id_fk", + "tableFrom": "backup_storage", + "columnsFrom": [ + "backup_id" + ], + "tableTo": "backups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "backup_storage_storage_channel_id_storage_channel_id_fk": { + "name": "backup_storage_storage_channel_id_storage_channel_id_fk", + "tableFrom": "backup_storage", + "columnsFrom": [ + "storage_channel_id" + ], + "tableTo": "storage_channel", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.healthcheck_log": { + "name": "healthcheck_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "healthcheck_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "healthcheck_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_log": { + "name": "job_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "restoration_id": { + "name": "restoration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "entry_type": { + "name": "entry_type", + "type": "job_log_entry_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "job_log_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "job_log_backup_id_backups_id_fk": { + "name": "job_log_backup_id_backups_id_fk", + "tableFrom": "job_log", + "columnsFrom": [ + "backup_id" + ], + "tableTo": "backups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "job_log_restoration_id_restorations_id_fk": { + "name": "job_log_restoration_id_restorations_id_fk", + "tableFrom": "job_log", + "columnsFrom": [ + "restoration_id" + ], + "tableTo": "restorations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.user_themes": { + "name": "user_themes", + "schema": "public", + "values": [ + "light", + "dark", + "system" + ] + }, + "public.retention_policy_type": { + "name": "retention_policy_type", + "schema": "public", + "values": [ + "count", + "days", + "gfs" + ] + }, + "public.provider_kind": { + "name": "provider_kind", + "schema": "public", + "values": [ + "slack", + "smtp", + "discord", + "telegram", + "gotify", + "ntfy", + "webhook", + "nextcloud", + "teams", + "pushover" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "error_backup", + "error_restore", + "success_restore", + "success_backup", + "weekly_report", + "error_health_agent", + "error_health_database" + ] + }, + "public.level": { + "name": "level", + "schema": "public", + "values": [ + "critical", + "warning", + "info" + ] + }, + "public.provider_storage_kind": { + "name": "provider_storage_kind", + "schema": "public", + "values": [ + "local", + "s3", + "google-drive", + "blob" + ] + }, + "public.backup_storage_status": { + "name": "backup_storage_status", + "schema": "public", + "values": [ + "pending", + "success", + "failed" + ] + }, + "public.healthcheck_status": { + "name": "healthcheck_status", + "schema": "public", + "values": [ + "success", + "failed" + ] + }, + "public.healthcheck_kind": { + "name": "healthcheck_kind", + "schema": "public", + "values": [ + "database", + "agent" + ] + }, + "public.job_log_entry_type": { + "name": "job_log_entry_type", + "schema": "public", + "values": [ + "log", + "command" + ] + }, + "public.job_log_level": { + "name": "job_log_level", + "schema": "public", + "values": [ + "debug", + "info", + "warn", + "error" + ] + }, + "public.dbms_status": { + "name": "dbms_status", + "schema": "public", + "values": [ + "postgresql", + "mysql", + "mariadb", + "mongodb", + "sqlite", + "redis", + "valkey", + "firebird", + "mssql" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "waiting", + "ongoing", + "failed", + "success" + ] + }, + "public.type_storage": { + "name": "type_storage", + "schema": "public", + "values": [ + "local", + "s3" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 806f6262..3b8860f2 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -456,6 +456,20 @@ "when": 1781960184847, "tag": "0064_orange_richard_fisk", "breakpoints": true + }, + { + "idx": 65, + "version": "7", + "when": 1782474059918, + "tag": "0065_mighty_killer_shrike", + "breakpoints": true + }, + { + "idx": 66, + "version": "7", + "when": 1782474105293, + "tag": "0066_milky_puma", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/01_setting.ts b/src/db/schema/01_setting.ts index ee72ed6b..79914923 100644 --- a/src/db/schema/01_setting.ts +++ b/src/db/schema/01_setting.ts @@ -20,10 +20,11 @@ export const setting = pgTable("settings", { defaultStorageChannelId: uuid('default_storage_channel_id') .references(() => storageChannel.id, {onDelete: "set null"}), encryption: boolean("encryption").default(false), + onboarding: boolean("onboarding").default(false).notNull(), ...timestamps }); -export const settingRelations = relations(setting, ({one, many}) => ({ +export const settingRelations = relations(setting, ({one}) => ({ storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}), notificationChannel: one(notificationChannel, {fields: [setting.defaultNotificationChannelId], references: [notificationChannel.id]}), })); diff --git a/src/db/services/agent.ts b/src/db/services/agent.ts index 6638f395..ce7ed2eb 100644 --- a/src/db/services/agent.ts +++ b/src/db/services/agent.ts @@ -1,43 +1,41 @@ -import {and, desc, eq, sql} from "drizzle-orm"; -import {db} from "@/db"; -import {Agent, agent, organizationAgent} from "@/db/schema/08_agent"; -import {Database, database} from "@/db/schema/07_database"; +"use server"; + +import { and, desc, eq, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { Agent, agent, organizationAgent } from "@/db/schema/08_agent"; +import { Database, database } from "@/db/schema/07_database"; export async function getOrganizationAgents(organizationId: string) { - - return await db - .select({ - id: agent.id, - name: agent.name, - organizationId: agent.organizationId, - slug: agent.slug, - healthErrorCount: agent.healthErrorCount, - description: agent.description, - isArchived: agent.isArchived, - lastContact: agent.lastContact, - version: agent.version, - updatedAt: agent.updatedAt, - createdAt: agent.createdAt, - deletedAt: agent.deletedAt, - databases: sql` + return (await db + .select({ + id: agent.id, + name: agent.name, + organizationId: agent.organizationId, + slug: agent.slug, + healthErrorCount: agent.healthErrorCount, + description: agent.description, + isArchived: agent.isArchived, + lastContact: agent.lastContact, + version: agent.version, + updatedAt: agent.updatedAt, + createdAt: agent.createdAt, + deletedAt: agent.deletedAt, + databases: sql` COALESCE( json_agg(${database}.*) FILTER (WHERE ${database}.id IS NOT NULL), '[]' ) `, - }) - .from(organizationAgent) - .innerJoin( - agent, - eq(organizationAgent.agentId, agent.id) - ) - .leftJoin(database, eq(database.agentId, agent.id)) - .groupBy(agent.id) - .orderBy(desc(agent.createdAt)) - .where( - and( - eq(organizationAgent.organizationId, organizationId), - eq(agent.isArchived, false) - ) - ) as unknown as Agent[]; + }) + .from(organizationAgent) + .innerJoin(agent, eq(organizationAgent.agentId, agent.id)) + .leftJoin(database, eq(database.agentId, agent.id)) + .groupBy(agent.id) + .orderBy(desc(agent.createdAt)) + .where( + and( + eq(organizationAgent.organizationId, organizationId), + eq(agent.isArchived, false), + ), + )) as unknown as Agent[]; } diff --git a/src/db/services/backup.ts b/src/db/services/backup.ts index d9234be4..717d013b 100644 --- a/src/db/services/backup.ts +++ b/src/db/services/backup.ts @@ -1,19 +1,19 @@ -"use server" -import {eq} from "drizzle-orm"; +"use server"; +import { eq } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {db} from "@/db"; +import { db } from "@/db"; export async function getDatabaseBackups(databaseId: string) { - return await db.query.backup.findMany({ - where: eq(drizzleDb.schemas.backup.databaseId, databaseId), + return await db.query.backup.findMany({ + where: eq(drizzleDb.schemas.backup.databaseId, databaseId), + with: { + restorations: true, + storages: { with: { - restorations: true, - storages: { - with: { - storageChannel: true, - }, - }, + storageChannel: true, }, - orderBy: (b, {desc}) => [desc(b.createdAt)], - }); + }, + }, + orderBy: (b, { desc }) => [desc(b.createdAt)], + }); } diff --git a/src/db/services/database.ts b/src/db/services/database.ts index f0ea0d4b..77113426 100644 --- a/src/db/services/database.ts +++ b/src/db/services/database.ts @@ -1,39 +1,123 @@ -"use server" -import {db} from "@/db"; -import {DatabaseWith} from "@/db/schema/07_database"; -import {AgentWith} from "@/db/schema/08_agent"; +"use server"; +import { inArray } from "drizzle-orm"; +import { db } from "@/db"; +import { database, retentionPolicy } from "@/db/schema/07_database"; +import { alertPolicy } from "@/db/schema/10_alert-policy"; +import { storagePolicy } from "@/db/schema/13_storage-policy"; +import { DatabaseWith } from "@/db/schema/07_database"; +import { AgentWith } from "@/db/schema/08_agent"; +import type { + OnboardingDbSettings, + EventKind, +} from "@/features/onboarding/types"; export async function getOrganizationAvailableDatabases( - organizationId: string, - projectId?: string + organizationId: string, + projectId?: string, ) { + const availableDatabases = (await db.query.database.findMany({ + where: (db, { eq, or, isNull }) => + projectId + ? or(isNull(db.projectId), eq(db.projectId, projectId)) + : isNull(db.projectId), + with: { + agent: { + with: { + organizations: true, + }, + }, + project: true, + backups: true, + restorations: true, + }, + orderBy: (db, { desc }) => [desc(db.createdAt)], + })) as DatabaseWith[]; - const availableDatabases = ( - await db.query.database.findMany({ - where: (db, { eq, or, isNull }) => - projectId - ? or(isNull(db.projectId), eq(db.projectId, projectId)) - : isNull(db.projectId), - with: { - agent: { - with: { - organizations: true - } - }, - project: true, - backups: true, - restorations: true, - }, - orderBy: (db, {desc}) => [desc(db.createdAt)], - }) - ) as DatabaseWith[]; - - return availableDatabases.filter(db => { - const agent = db.agent as AgentWith; - if (agent?.isArchived) return false; - return ( - agent?.organizationId === organizationId || - agent?.organizations?.some(org => org.organizationId === organizationId) - ); - }) + return availableDatabases.filter((db) => { + const agent = db.agent as AgentWith; + if (agent?.isArchived) return false; + return ( + agent?.organizationId === organizationId || + agent?.organizations?.some((org) => org.organizationId === organizationId) + ); + }); +} + +export async function getDatabasesSettings( + databaseIds: string[], +): Promise> { + if (databaseIds.length === 0) return {}; + + const [retentionPolicies, dbs, alertPolicies, storagePolicies] = + await Promise.all([ + db + .select() + .from(retentionPolicy) + .where(inArray(retentionPolicy.databaseId, databaseIds)), + db + .select({ id: database.id, backupPolicy: database.backupPolicy }) + .from(database) + .where(inArray(database.id, databaseIds)), + db + .select() + .from(alertPolicy) + .where(inArray(alertPolicy.databaseId, databaseIds)), + db + .select() + .from(storagePolicy) + .where(inArray(storagePolicy.databaseId, databaseIds)), + ]); + + const result: Record = {}; + + for (const dbId of databaseIds) { + const rp = retentionPolicies.find((r) => r.databaseId === dbId); + const dbRow = dbs.find((d) => d.id === dbId); + const alerts = alertPolicies.filter((a) => a.databaseId === dbId); + const storages = storagePolicies.filter((s) => s.databaseId === dbId); + + const settings: OnboardingDbSettings = {}; + + if (rp) { + settings.retention = { + type: rp.type, + count: rp.count ?? 7, + days: rp.days ?? 30, + gfs: { + daily: rp.gfsDaily ?? 7, + weekly: rp.gfsWeekly ?? 4, + monthly: rp.gfsMonthly ?? 12, + yearly: rp.gfsYearly ?? 3, + }, + }; + } + + if (dbRow) { + if (dbRow.backupPolicy) { + settings.backupMethod = "automatic"; + settings.backupCron = dbRow.backupPolicy; + } else { + settings.backupMethod = "manual"; + } + } + + if (alerts.length > 0) { + settings.notificationPolicies = alerts.map((a) => ({ + channelId: a.notificationChannelId, + eventKinds: a.eventKinds as EventKind[], + enabled: a.enabled, + })); + } + + if (storages.length > 0) { + settings.storagePolicies = storages.map((s) => ({ + channelId: s.storageChannelId, + enabled: s.enabled, + })); + } + + result[dbId] = settings; + } + + return result; } diff --git a/src/db/services/healthcheck.ts b/src/db/services/healthcheck.ts index 6c16ba17..16abd9ec 100644 --- a/src/db/services/healthcheck.ts +++ b/src/db/services/healthcheck.ts @@ -1,185 +1,200 @@ -import {db} from "@/db"; +"use server"; + +import { db } from "@/db"; import * as drizzleDb from "@/db"; -import {and, eq, gte, isNotNull, lt} from "drizzle-orm"; -import {dispatchNotification} from "@/features/notifications/notifications.dispatch"; -import {EventPayload} from "@/features/notifications/notifications.types"; -import {logger} from "@/lib/logger"; +import { and, eq, gte, isNotNull, lt } from "drizzle-orm"; +import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch"; +import { EventPayload } from "@/features/notifications/types"; +import { logger } from "@/lib/logger"; -const log = logger.child({module: "tasks/healthcheck"}); +const log = logger.child({ module: "tasks/healthcheck" }); -export async function getHealthLast12hLogs({id}: { id: string }) { - const now = new Date() - const since = new Date(now.getTime() - 12 * 60 * 60 * 1000) +export async function getHealthLast12hLogs({ id }: { id: string }) { + const now = new Date(); + const since = new Date(now.getTime() - 12 * 60 * 60 * 1000); - return db - .select() - .from(drizzleDb.schemas.healthcheckLog) - .where( - and( - eq(drizzleDb.schemas.healthcheckLog.objectId, id), - gte(drizzleDb.schemas.healthcheckLog.date, since) - ) - ) + return db + .select() + .from(drizzleDb.schemas.healthcheckLog) + .where( + and( + eq(drizzleDb.schemas.healthcheckLog.objectId, id), + gte(drizzleDb.schemas.healthcheckLog.date, since), + ), + ); } export async function deleteHealthLogsOlderThan12h() { - const now = new Date() - const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000) + const now = new Date(); + const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000); - const logsToDelete = await db - .select() - .from(drizzleDb.schemas.healthcheckLog) - .where( - lt(drizzleDb.schemas.healthcheckLog.date, threshold) - ) + const logsToDelete = await db + .select() + .from(drizzleDb.schemas.healthcheckLog) + .where(lt(drizzleDb.schemas.healthcheckLog.date, threshold)); - log.info({name: "deleteHealthLogsOlderThan12h"},`Number of logs found to delete: ${logsToDelete.length}`) + log.info( + { name: "deleteHealthLogsOlderThan12h" }, + `Number of logs found to delete: ${logsToDelete.length}`, + ); - await db - .delete(drizzleDb.schemas.healthcheckLog) - .where( - lt(drizzleDb.schemas.healthcheckLog.date, threshold) - ) + await db + .delete(drizzleDb.schemas.healthcheckLog) + .where(lt(drizzleDb.schemas.healthcheckLog.date, threshold)); - return logsToDelete.length + return logsToDelete.length; } export async function checkAgentsHealthError() { - const agents = await db.query.agent.findMany({ - where: isNotNull(drizzleDb.schemas.agent.lastContact), - }); + const agents = await db.query.agent.findMany({ + where: isNotNull(drizzleDb.schemas.agent.lastContact), + }); - const settings = await db.query.setting.findFirst({ - where: (fields, {eq}) => eq(fields.name, "system"), - }); + const settings = await db.query.setting.findFirst({ + where: (fields, { eq }) => eq(fields.name, "system"), + }); - if (!settings) { - throw new Error("System settings not found"); - } - - if (!settings.defaultNotificationChannelId) { - log.error({name: "checkAgentsHealthError"},`No default notification channel id found.`) - return - } - - const now = new Date(); - - for (const agent of agents) { - if (!agent.lastContact) continue; - - const lastContactDate = new Date(agent.lastContact); - const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60; - - if (diffMinutes > 10) { - if ((agent.healthErrorCount ?? 0) < 3) { - - const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1 - await db.update(drizzleDb.schemas.agent) - .set({ - healthErrorCount: newHealthErrorCount, - }) - .where(eq(drizzleDb.schemas.agent.id, agent.id)); - - const payload: EventPayload = { - title: "Agent down", - message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`, - level: "critical", - event: "error_health_agent", - data: { - agent: agent.name, - id: agent.id, - error: "Agent is down", - }, - }; - log.info({name: "checkAgentsHealthError", payload: payload},`Agent Healthcheck Notification`) - - await dispatchNotification( - payload, - undefined, - settings.defaultNotificationChannelId, - undefined - ); - } - - } + if (!settings) { + throw new Error("System settings not found"); + } + + if (!settings.defaultNotificationChannelId) { + log.error( + { name: "checkAgentsHealthError" }, + `No default notification channel id found.`, + ); + return; + } + + const now = new Date(); + + for (const agent of agents) { + if (!agent.lastContact) continue; + + const lastContactDate = new Date(agent.lastContact); + const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60; + + if (diffMinutes > 10) { + if ((agent.healthErrorCount ?? 0) < 3) { + const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1; + await db + .update(drizzleDb.schemas.agent) + .set({ + healthErrorCount: newHealthErrorCount, + }) + .where(eq(drizzleDb.schemas.agent.id, agent.id)); + + const payload: EventPayload = { + title: "Agent down", + message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`, + level: "critical", + event: "error_health_agent", + data: { + agent: agent.name, + id: agent.id, + error: "Agent is down", + }, + }; + log.info( + { name: "checkAgentsHealthError", payload: payload }, + `Agent Healthcheck Notification`, + ); + + await dispatchNotification( + payload, + undefined, + settings.defaultNotificationChannelId, + undefined, + ); + } } + } } - - export async function checkDatabasesHealthError() { + const databases = await db.query.database.findMany({ + where: isNotNull(drizzleDb.schemas.database.lastContact), + with: { + agent: true, + alertPolicies: true, + }, + }); - const databases = await db.query.database.findMany({ - where: isNotNull(drizzleDb.schemas.database.lastContact), - with: { - agent: true, - alertPolicies: true + const now = new Date(); + + for (const database of databases) { + if (!database.lastContact) continue; + + const lastContactDate = new Date(database.lastContact); + const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60; + + if (diffMinutes > 10) { + if ((database.healthErrorCount ?? 0) < 3) { + const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1; + await db + .update(drizzleDb.schemas.database) + .set({ + healthErrorCount: newHealthErrorCount, + }) + .where(eq(drizzleDb.schemas.database.id, database.id)); + + const settings = await db.query.setting.findFirst({ + where: eq(drizzleDb.schemas.setting.name, "system"), + with: { notificationChannel: true }, + }); + + const defaultPolicy = settings?.notificationChannel + ? [ + { + id: null, + notificationChannelId: settings.notificationChannel.id, + enabled: settings.notificationChannel.enabled, + eventKinds: ["error_health_database"], + }, + ] + : []; + + const policiesToUse = + database.alertPolicies && database.alertPolicies.length > 0 + ? database.alertPolicies.filter( + (policy) => + policy.enabled && + policy.eventKinds.includes("error_health_database"), + ) + : defaultPolicy; + + if (!policiesToUse || policiesToUse.length === 0) { + continue; } - }) - const now = new Date(); + const promises = policiesToUse.map((alertPolicy) => { + const payload: EventPayload = { + title: "Database down", + message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`, + level: "critical", + event: "error_health_database", + data: { + agent: database.name, + id: database.id, + error: "Database is down", + }, + }; - for (const database of databases) { - if (!database.lastContact) continue; + log.info( + { name: "checkDatabasesHealthError", payload: payload }, + `Database Healthcheck Notification`, + ); - const lastContactDate = new Date(database.lastContact); - const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60; + return dispatchNotification( + payload, + alertPolicy.id == null ? undefined : alertPolicy.id, + alertPolicy.id ? undefined : alertPolicy.notificationChannelId, + undefined, + ); + }); - if (diffMinutes > 10) { - if ((database.healthErrorCount ?? 0) < 3) { - - const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1 - await db.update(drizzleDb.schemas.database) - .set({ - healthErrorCount: newHealthErrorCount, - }) - .where(eq(drizzleDb.schemas.database.id, database.id)); - - const settings = await db.query.setting.findFirst({ - where: eq(drizzleDb.schemas.setting.name, "system"), - with: { notificationChannel: true }, - }); - - const defaultPolicy = settings?.notificationChannel - ? [{ - id: null, - notificationChannelId: settings.notificationChannel.id, - enabled: settings.notificationChannel.enabled, - eventKinds: ["error_health_database"] - }] - : []; - - const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0) - ? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes("error_health_database")) - : defaultPolicy; - - if (!policiesToUse || policiesToUse.length === 0) { - continue - } - - const promises = policiesToUse.map(alertPolicy => { - - const payload: EventPayload = { - title: "Database down", - message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`, - level: "critical", - event: "error_health_database", - data: { - agent: database.name, - id: database.id, - error: "Database is down", - }, - }; - - log.info({name: "checkDatabasesHealthError", payload: payload},`Database Healthcheck Notification`) - - return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined); - }); - - await Promise.all(promises); - } - } + await Promise.all(promises); + } } + } } - - diff --git a/src/db/services/notification-channel.ts b/src/db/services/notification-channel.ts index 06cafa21..50040e59 100644 --- a/src/db/services/notification-channel.ts +++ b/src/db/services/notification-channel.ts @@ -1,30 +1,62 @@ -import {desc, eq} from "drizzle-orm"; -import {db} from "@/db"; -import { - NotificationChannel, - notificationChannel, - organizationNotificationChannel -} from "@/db/schema/09_notification-channel"; -import {storageChannel} from "@/db/schema/12_storage-channel"; +"use server"; -export async function getOrganizationChannels(organizationId: string) { - return await db - .select({ - id: notificationChannel.id, - name: notificationChannel.name, - provider: notificationChannel.provider, - config: notificationChannel.config, - enabled: notificationChannel.enabled, - updatedAt: notificationChannel.updatedAt, - createdAt: notificationChannel.createdAt, - deletedAt: notificationChannel.deletedAt, - organizationId: notificationChannel.organizationId - }) - .from(organizationNotificationChannel) - .innerJoin( - notificationChannel, - eq(organizationNotificationChannel.notificationChannelId, notificationChannel.id) - ) - .orderBy(desc(notificationChannel.createdAt)) - .where(eq(organizationNotificationChannel.organizationId, organizationId)) as unknown as NotificationChannel[]; +import { desc, eq, isNull } from "drizzle-orm"; +import { db } from "@/db"; +import { + NotificationChannel, + notificationChannel, + organizationNotificationChannel, +} from "@/db/schema/09_notification-channel"; + +export async function getOrganizationChannels( + organizationId: string, +): Promise { + const [orgChannels, systemChannels] = await Promise.all([ + db + .select({ + id: notificationChannel.id, + name: notificationChannel.name, + provider: notificationChannel.provider, + config: notificationChannel.config, + enabled: notificationChannel.enabled, + updatedAt: notificationChannel.updatedAt, + createdAt: notificationChannel.createdAt, + deletedAt: notificationChannel.deletedAt, + organizationId: notificationChannel.organizationId, + }) + .from(organizationNotificationChannel) + .innerJoin( + notificationChannel, + eq( + organizationNotificationChannel.notificationChannelId, + notificationChannel.id, + ), + ) + .orderBy(desc(notificationChannel.createdAt)) + .where( + eq(organizationNotificationChannel.organizationId, organizationId), + ), + db + .select({ + id: notificationChannel.id, + name: notificationChannel.name, + provider: notificationChannel.provider, + config: notificationChannel.config, + enabled: notificationChannel.enabled, + updatedAt: notificationChannel.updatedAt, + createdAt: notificationChannel.createdAt, + deletedAt: notificationChannel.deletedAt, + organizationId: notificationChannel.organizationId, + }) + .from(notificationChannel) + .orderBy(desc(notificationChannel.createdAt)) + .where(isNull(notificationChannel.organizationId)), + ]); + + const seen = new Set(); + return [...orgChannels, ...systemChannels].filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }) as NotificationChannel[]; } diff --git a/src/db/services/notification-log.ts b/src/db/services/notification-log.ts index 6366d4bd..de497e57 100644 --- a/src/db/services/notification-log.ts +++ b/src/db/services/notification-log.ts @@ -1,81 +1,91 @@ -import {and, desc, eq, gte, lte} from 'drizzle-orm'; -import {NotificationLevel, notificationLog} from "@/db/schema/11_notification-log"; -import {notificationChannel} from "@/db/schema/09_notification-channel"; -import {db} from "@/db"; -import {Json} from "drizzle-zod"; +"use server"; + +import { and, desc, eq, gte, lte } from "drizzle-orm"; +import { + NotificationLevel, + notificationLog, +} from "@/db/schema/11_notification-log"; +import { notificationChannel } from "@/db/schema/09_notification-channel"; +import { db } from "@/db"; +import { Json } from "drizzle-zod"; export type NotificationLogWithRelations = { - id: string; + id: string; + title: string; + level: NotificationLevel; + success: boolean; + error: string | null; + sentAt: Date; + payload: Json | null; + content: { title: string; - level: NotificationLevel; - success: boolean; - error: string | null; - sentAt: Date; - payload: Json | null; - content: { - title: string; - message: string; - }, - channel: { - name: string; - provider: string; - } | null; - policy: { - event: string | null; - } | null; + message: string; + }; + channel: { + name: string; + provider: string; + } | null; + policy: { + event: string | null; + } | null; }; -export async function getNotificationHistory( - filters?: { - channelId?: string; - policyId?: string; - organizationId?: string; - level?: NotificationLevel; - success?: boolean; - from?: Date; - to?: Date; - limit?: number; - } -): Promise { - const where = []; - if (filters?.channelId) where.push(eq(notificationLog.channelId, filters.channelId)); - if (filters?.policyId) where.push(eq(notificationLog.policyId, filters.policyId)); - if (filters?.organizationId) where.push(eq(notificationLog.organizationId, filters.organizationId)); - if (filters?.level) where.push(eq(notificationLog.level, filters.level)); - if (typeof filters?.success === 'boolean') where.push(eq(notificationLog.success, filters.success)); - if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from)); - if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to)); +export async function getNotificationHistory(filters?: { + channelId?: string; + policyId?: string; + organizationId?: string; + level?: NotificationLevel; + success?: boolean; + from?: Date; + to?: Date; + limit?: number; +}): Promise { + const where = []; + if (filters?.channelId) + where.push(eq(notificationLog.channelId, filters.channelId)); + if (filters?.policyId) + where.push(eq(notificationLog.policyId, filters.policyId)); + if (filters?.organizationId) + where.push(eq(notificationLog.organizationId, filters.organizationId)); + if (filters?.level) where.push(eq(notificationLog.level, filters.level)); + if (typeof filters?.success === "boolean") + where.push(eq(notificationLog.success, filters.success)); + if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from)); + if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to)); - const rows = await db - .select({ - id: notificationLog.id, - title: notificationLog.title, - level: notificationLog.level, - success: notificationLog.success, - error: notificationLog.error, - sentAt: notificationLog.sentAt, - payload: notificationLog.payload, - content: { - title: notificationLog.title, - message: notificationLog.message, - }, - channel: { - name: notificationLog.providerName, - provider: notificationLog.provider, - }, - policy: { - event: notificationLog.event, - }, - }) - .from(notificationLog) - .leftJoin(notificationChannel, eq(notificationLog.channelId, notificationChannel.id)) - // .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id)) - .where(and(...where)) - .orderBy(desc(notificationLog.sentAt)) - .limit(filters?.limit || 100); + const rows = await db + .select({ + id: notificationLog.id, + title: notificationLog.title, + level: notificationLog.level, + success: notificationLog.success, + error: notificationLog.error, + sentAt: notificationLog.sentAt, + payload: notificationLog.payload, + content: { + title: notificationLog.title, + message: notificationLog.message, + }, + channel: { + name: notificationLog.providerName, + provider: notificationLog.provider, + }, + policy: { + event: notificationLog.event, + }, + }) + .from(notificationLog) + .leftJoin( + notificationChannel, + eq(notificationLog.channelId, notificationChannel.id), + ) + // .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id)) + .where(and(...where)) + .orderBy(desc(notificationLog.sentAt)) + .limit(filters?.limit || 100); - return rows.map(row => ({ - ...row, - payload: row.payload as Json, - })); + return rows.map((row) => ({ + ...row, + payload: row.payload as Json, + })); } diff --git a/src/db/services/organization.ts b/src/db/services/organization.ts new file mode 100644 index 00000000..2ede3e96 --- /dev/null +++ b/src/db/services/organization.ts @@ -0,0 +1,29 @@ +"use server"; + +import { db } from "@/db"; +import { and, eq, inArray, ne } from "drizzle-orm"; +import { member } from "@/db/schema/04_member"; +import { organization } from "@/db/schema/03_organization"; + +export async function getUserOrganization(userId: string) { + const memberRow = await db.query.member.findFirst({ + columns: { organizationId: true }, + where: eq(member.userId, userId), + }); + if (!memberRow) return null; + return db.query.organization.findFirst({ + where: eq(organization.id, memberRow.organizationId), + }); +} + +export async function getUserOwnOrganization(userId: string) { + const memberRows = await db.query.member.findMany({ + columns: { organizationId: true }, + where: eq(member.userId, userId), + }); + if (!memberRows.length) return null; + const orgIds = memberRows.map((r) => r.organizationId); + return db.query.organization.findFirst({ + where: and(inArray(organization.id, orgIds), ne(organization.slug, "default")), + }); +} diff --git a/src/db/services/project.ts b/src/db/services/project.ts index 6bc832ae..7270b1dc 100644 --- a/src/db/services/project.ts +++ b/src/db/services/project.ts @@ -1,61 +1,77 @@ -import {getOrganization} from "@/lib/auth/auth"; -import {db} from "@/db"; -import {and, eq} from "drizzle-orm"; +"use server"; + +import { getOrganization } from "@/lib/auth/auth"; +import { db } from "@/db"; +import { and, eq } from "drizzle-orm"; import * as drizzleDb from "@/db"; +import { project } from "@/db/schema/06_project"; +export async function getOrganizationProject(organizationId: string) { + return db.query.project.findFirst({ + where: eq(project.organizationId, organizationId), + with: { + databases: true, + }, + }); +} -export const getOrganizationProjectDatabases = async ({organizationSlug, projectId}: { - organizationSlug: string, projectId: string +export const getOrganizationProjectDatabases = async ({ + organizationSlug, + projectId, +}: { + organizationSlug: string; + projectId: string; }) => { - try { + try { + const organization = await getOrganization({}); - const organization = await getOrganization({}); - - if (!organization) { - return { - name: "ErrorGettingOrganizationProjectDatabases", - message: "No organization found.", - status: 400, - cause: "Unknown error occurred.", - }; - } - const databasesProject = await db.query.project.findFirst({ - where: and(eq(drizzleDb.schemas.project.organizationId, organization.id), eq(drizzleDb.schemas.project.id, projectId)), - with: { - databases: true - } - }); - - if (!databasesProject) { - return { - name: "ErrorGettingOrganizationProjectDatabases", - message: "No organization found.", - status: 400, - cause: "Unknown error occurred.", - }; - } - - return { - data: databasesProject.databases, - ids: databasesProject.databases.map((project) => project.id) - } - - - } catch (e: any) { - const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error"; - const status = e?.response?.status || 500; - - console.error("API GettingOrganizationProjectDatabases error:", { - message: errorMessage, - status, - raw: e, - }); - - throw { - name: "ErrorGettingOrganizationProjectDatabases", - message: errorMessage, - status, - cause: e, - }; + if (!organization) { + return { + name: "ErrorGettingOrganizationProjectDatabases", + message: "No organization found.", + status: 400, + cause: "Unknown error occurred.", + }; } + const databasesProject = await db.query.project.findFirst({ + where: and( + eq(drizzleDb.schemas.project.organizationId, organization.id), + eq(drizzleDb.schemas.project.id, projectId), + ), + with: { + databases: true, + }, + }); + + if (!databasesProject) { + return { + name: "ErrorGettingOrganizationProjectDatabases", + message: "No organization found.", + status: 400, + cause: "Unknown error occurred.", + }; + } + + return { + data: databasesProject.databases, + ids: databasesProject.databases.map((project) => project.id), + }; + } catch (e: any) { + const errorMessage = + e?.response?.data?.message || e?.message || "Unknown auth error"; + const status = e?.response?.status || 500; + + console.error("API GettingOrganizationProjectDatabases error:", { + message: errorMessage, + status, + raw: e, + }); + + throw { + name: "ErrorGettingOrganizationProjectDatabases", + message: errorMessage, + status, + cause: e, + }; + } }; diff --git a/src/db/services/setting.ts b/src/db/services/setting.ts new file mode 100644 index 00000000..fb9f7b2c --- /dev/null +++ b/src/db/services/setting.ts @@ -0,0 +1,12 @@ +"use server"; + +import { db } from "@/db"; + +export async function getSettings() { + return db.query.setting.findFirst(); +} + +export async function isOnboardingDone(): Promise { + const settings = await db.query.setting.findFirst(); + return settings?.onboarding ?? false; +} diff --git a/src/db/services/storage-channel.ts b/src/db/services/storage-channel.ts index 9cf6227a..03dc3aa6 100644 --- a/src/db/services/storage-channel.ts +++ b/src/db/services/storage-channel.ts @@ -1,25 +1,57 @@ -import {desc, eq} from "drizzle-orm"; -import {db} from "@/db"; -import {organizationStorageChannel, StorageChannel, storageChannel} from "@/db/schema/12_storage-channel"; +"use server"; -export async function getOrganizationStorageChannels(organizationId: string) { - return await db - .select({ - id: storageChannel.id, - name: storageChannel.name, - provider: storageChannel.provider, - organizationId: storageChannel.organizationId, - config: storageChannel.config, - enabled: storageChannel.enabled, - updatedAt: storageChannel.updatedAt, - createdAt: storageChannel.createdAt, - deletedAt: storageChannel.deletedAt, - }) - .from(organizationStorageChannel) - .innerJoin( - storageChannel, - eq(organizationStorageChannel.storageChannelId, storageChannel.id) - ) - .orderBy(desc(storageChannel.createdAt)) - .where(eq(organizationStorageChannel.organizationId, organizationId)) as unknown as StorageChannel[]; +import { desc, eq, isNull } from "drizzle-orm"; +import { db } from "@/db"; +import { + organizationStorageChannel, + StorageChannel, + storageChannel, +} from "@/db/schema/12_storage-channel"; + +export async function getOrganizationStorageChannels( + organizationId: string, +): Promise { + const [orgChannels, systemChannels] = await Promise.all([ + db + .select({ + id: storageChannel.id, + name: storageChannel.name, + provider: storageChannel.provider, + organizationId: storageChannel.organizationId, + config: storageChannel.config, + enabled: storageChannel.enabled, + updatedAt: storageChannel.updatedAt, + createdAt: storageChannel.createdAt, + deletedAt: storageChannel.deletedAt, + }) + .from(organizationStorageChannel) + .innerJoin( + storageChannel, + eq(organizationStorageChannel.storageChannelId, storageChannel.id), + ) + .orderBy(desc(storageChannel.createdAt)) + .where(eq(organizationStorageChannel.organizationId, organizationId)), + db + .select({ + id: storageChannel.id, + name: storageChannel.name, + provider: storageChannel.provider, + organizationId: storageChannel.organizationId, + config: storageChannel.config, + enabled: storageChannel.enabled, + updatedAt: storageChannel.updatedAt, + createdAt: storageChannel.createdAt, + deletedAt: storageChannel.deletedAt, + }) + .from(storageChannel) + .orderBy(desc(storageChannel.createdAt)) + .where(isNull(storageChannel.organizationId)), + ]); + + const seen = new Set(); + return [...orgChannels, ...systemChannels].filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }) as StorageChannel[]; } diff --git a/src/db/services/user.ts b/src/db/services/user.ts index f847e15d..676a780d 100644 --- a/src/db/services/user.ts +++ b/src/db/services/user.ts @@ -1,40 +1,49 @@ -import {SignUpUser} from "@/types/auth"; -import {hashPassword} from "better-auth/crypto"; -import {db} from "@/db"; -import * as drizzleDb from "@/db"; -import {User, UserThemeEnum} from "@/db/schema/02_user"; -import {assertValidPassword} from "@/utils/password"; +"use server"; +import { SignUpUser } from "@/types/auth"; +import { hashPassword } from "better-auth/crypto"; +import { db } from "@/db"; +import * as drizzleDb from "@/db"; +import { User, UserThemeEnum } from "@/db/schema/02_user"; +import { assertValidPassword } from "@/utils/password"; + +export async function hasUsers(): Promise { + const result = await db.select().from(drizzleDb.schemas.user).limit(1); + return result.length > 0; +} export async function createUserDb(data: SignUpUser): Promise { - assertValidPassword(data.password); + assertValidPassword(data.password); - const now = new Date(); - const userId = crypto.randomUUID(); + const now = new Date(); + const userId = crypto.randomUUID(); - const [newUser] = await db.insert(drizzleDb.schemas.user).values({ - ...data, - id: userId, - name: data.name, - email: data.email, - emailVerified: true, - role: data.role, - createdAt: now, - updatedAt: now, - theme: data.theme as UserThemeEnum, - }).returning(); + const [newUser] = await db + .insert(drizzleDb.schemas.user) + .values({ + ...data, + id: userId, + name: data.name, + email: data.email, + emailVerified: true, + role: data.role, + createdAt: now, + updatedAt: now, + theme: data.theme as UserThemeEnum, + }) + .returning(); - if (data.password) { - const hashedPassword = await hashPassword(data.password); - await db.insert(drizzleDb.schemas.account).values({ - providerId: "credential", - accountId: userId, - userId: userId, - password: hashedPassword, - createdAt: now, - updatedAt: now, - }); - } + if (data.password) { + const hashedPassword = await hashPassword(data.password); + await db.insert(drizzleDb.schemas.account).values({ + providerId: "credential", + accountId: userId, + userId: userId, + password: hashedPassword, + createdAt: now, + updatedAt: now, + }); + } - return newUser + return newUser; } diff --git a/src/env.mjs b/src/env.mjs index 54fe41a1..4eacf2f6 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -88,6 +88,8 @@ export const env = createEnv({ ALLOWED_GROUP: z.string().optional(), + SKIP_ONBOARDING: z.string().optional().default("false"), + AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"), AUTH_SIGNUP_ENABLED: z.string().optional().default("true"), AUTH_PASSKEY_ENABLED: z.string().optional().default("false"), @@ -174,6 +176,8 @@ export const env = createEnv({ ALLOWED_GROUP: process.env.ALLOWED_GROUP, + SKIP_ONBOARDING: process.env.SKIP_ONBOARDING, + AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED, AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED, AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED, diff --git a/src/features/agents/agent-delete.action.ts b/src/features/agents/actions/agent-delete.action.ts similarity index 100% rename from src/features/agents/agent-delete.action.ts rename to src/features/agents/actions/agent-delete.action.ts diff --git a/src/features/agents/agent-organizations.action.ts b/src/features/agents/actions/agent-organizations.action.ts similarity index 100% rename from src/features/agents/agent-organizations.action.ts rename to src/features/agents/actions/agent-organizations.action.ts diff --git a/src/features/agents/agents.action.ts b/src/features/agents/actions/agents.action.ts similarity index 97% rename from src/features/agents/agents.action.ts rename to src/features/agents/actions/agents.action.ts index e22dbce4..d3d488c1 100644 --- a/src/features/agents/agents.action.ts +++ b/src/features/agents/actions/agents.action.ts @@ -1,6 +1,6 @@ "use server"; import {action, ActionError, userAction} from "@/lib/safe-actions/actions"; -import {AgentSchema} from "@/features/agents/agents.schema"; +import {AgentSchema} from "@/features/agents/schemas/agents.schema"; import {z} from "zod"; import {eq, and, ne, count, desc} from "drizzle-orm"; import {db} from "@/db"; diff --git a/src/features/agents/keys.action.ts b/src/features/agents/actions/keys.action.ts similarity index 100% rename from src/features/agents/keys.action.ts rename to src/features/agents/actions/keys.action.ts diff --git a/src/features/agents/agent-card-key.tsx b/src/features/agents/components/agent-card-key.tsx similarity index 100% rename from src/features/agents/agent-card-key.tsx rename to src/features/agents/components/agent-card-key.tsx diff --git a/src/features/agents/agent-card.tsx b/src/features/agents/components/agent-card.tsx similarity index 98% rename from src/features/agents/agent-card.tsx rename to src/features/agents/components/agent-card.tsx index 3b1f5277..6f556ef0 100644 --- a/src/features/agents/agent-card.tsx +++ b/src/features/agents/components/agent-card.tsx @@ -11,7 +11,7 @@ import {Badge} from "@/components/ui/badge"; import {truncateWords} from "@/utils/text"; import {useIsMobile} from "@/hooks/use-mobile"; import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip"; -import {useAgentUpdateCheck} from "@/features/agents/use-agent-update-check"; +import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-check"; export type agentCardProps = { data: AgentWith; diff --git a/src/features/agents/agent-content.tsx b/src/features/agents/components/agent-content.tsx similarity index 94% rename from src/features/agents/agent-content.tsx rename to src/features/agents/components/agent-content.tsx index 6e6fe392..bfc95377 100644 --- a/src/features/agents/agent-content.tsx +++ b/src/features/agents/components/agent-content.tsx @@ -3,10 +3,10 @@ import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {Server} from "lucide-react"; import {formatDateLastContact} from "@/utils/date-formatting"; -import {AgentCardKey} from "@/features/agents/agent-card-key"; +import {AgentCardKey} from "@/features/agents/components/agent-card-key"; import {AgentWithDatabases} from "@/db/schema/08_agent"; import {useQuery} from "@tanstack/react-query"; -import {getAgentAction} from "@/features/agents/agents.action"; +import {getAgentAction} from "@/features/agents/actions/agents.action"; import { Accordion, AccordionContent, @@ -16,8 +16,8 @@ import { import {Separator} from "@/components/ui/separator"; import {Badge} from "@/components/ui/badge"; import {CardsWithPagination} from "@/components/common/cards-with-pagination"; -import {AgentDatabaseCard} from "@/features/agents/agent-database-card"; -import {HealthCheckGraph} from "@/features/database/health-grid"; +import {AgentDatabaseCard} from "@/features/agents/components/agent-database-card"; +import {HealthCheckGraph} from "@/features/database/components/health-grid"; import {HealthcheckLog} from "@/db/schema/15_healthcheck-log"; type AgentContentPageProps = { diff --git a/src/features/agents/agent-database-card.tsx b/src/features/agents/components/agent-database-card.tsx similarity index 100% rename from src/features/agents/agent-database-card.tsx rename to src/features/agents/components/agent-database-card.tsx diff --git a/src/features/agents/agent-database-columns.tsx b/src/features/agents/components/agent-database-columns.tsx similarity index 100% rename from src/features/agents/agent-database-columns.tsx rename to src/features/agents/components/agent-database-columns.tsx diff --git a/src/features/agents/agent-delete-button.tsx b/src/features/agents/components/agent-delete-button.tsx similarity index 96% rename from src/features/agents/agent-delete-button.tsx rename to src/features/agents/components/agent-delete-button.tsx index d7561f89..174aa325 100644 --- a/src/features/agents/agent-delete-button.tsx +++ b/src/features/agents/components/agent-delete-button.tsx @@ -5,7 +5,7 @@ import {ButtonWithConfirm} from "@/components/common/button-with-confirm"; import {useMutation} from "@tanstack/react-query"; import {useRouter} from "next/navigation"; import {toast} from "sonner"; -import {deleteAgentAction} from "@/features/agents/agent-delete.action"; +import {deleteAgentAction} from "@/features/agents/actions/agent-delete.action"; import {useIsMobile} from "@/hooks/use-mobile"; export type ButtonDeleteAgentProps = { diff --git a/src/features/agents/agent-dialog.tsx b/src/features/agents/components/agent-dialog.tsx similarity index 96% rename from src/features/agents/agent-dialog.tsx rename to src/features/agents/components/agent-dialog.tsx index 343d5117..9f7bbaf3 100644 --- a/src/features/agents/agent-dialog.tsx +++ b/src/features/agents/components/agent-dialog.tsx @@ -7,7 +7,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import {AgentForm} from "@/features/agents/agent-form"; +import {AgentForm} from "@/features/agents/components/agent-form"; import {Button, buttonVariants} from "@/components/ui/button"; import {Plus} from "lucide-react"; import {GearIcon} from "@radix-ui/react-icons"; @@ -16,7 +16,7 @@ import {useState} from "react"; import {useRouter} from "next/navigation"; import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; -import {AgentOrganisationForm} from "@/features/agents/agent-organizations.form"; +import {AgentOrganisationForm} from "@/features/agents/components/agent-organizations.form"; import {AgentWith} from "@/db/schema/08_agent"; type AgentDialogProps = { diff --git a/src/features/agents/agent-form.tsx b/src/features/agents/components/agent-form.tsx similarity index 97% rename from src/features/agents/agent-form.tsx rename to src/features/agents/components/agent-form.tsx index e9abc7b2..8d4da860 100644 --- a/src/features/agents/agent-form.tsx +++ b/src/features/agents/components/agent-form.tsx @@ -15,9 +15,9 @@ import {Button} from "@/components/ui/button"; import {useRouter} from "next/navigation"; import {useMutation, useQueryClient} from "@tanstack/react-query"; import {TooltipProvider} from "@/components/ui/tooltip"; -import {AgentSchema, AgentType} from "@/features/agents/agents.schema"; +import {AgentSchema, AgentType} from "@/features/agents/schemas/agents.schema"; import {toast} from "sonner"; -import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action"; +import {createAgentAction, updateAgentAction} from "@/features/agents/actions/agents.action"; import {OrganizationWithMembers} from "@/db/schema/03_organization"; export type agentFormProps = { diff --git a/src/features/agents/agent-key-modal.tsx b/src/features/agents/components/agent-key-modal.tsx similarity index 100% rename from src/features/agents/agent-key-modal.tsx rename to src/features/agents/components/agent-key-modal.tsx diff --git a/src/features/agents/agent-organizations.form.tsx b/src/features/agents/components/agent-organizations.form.tsx similarity index 97% rename from src/features/agents/agent-organizations.form.tsx rename to src/features/agents/components/agent-organizations.form.tsx index 197eb98f..797b9469 100644 --- a/src/features/agents/agent-organizations.form.tsx +++ b/src/features/agents/components/agent-organizations.form.tsx @@ -8,8 +8,8 @@ import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {MultiSelect} from "@/components/common/multi-select"; import {toast} from "sonner"; import {AgentWith} from "@/db/schema/08_agent"; -import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/agent-organizations.schema"; -import {updateAgentOrganizationsAction} from "@/features/agents/agent-organizations.action"; +import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/schemas/agent-organizations.schema"; +import {updateAgentOrganizationsAction} from "@/features/agents/actions/agent-organizations.action"; type AgentOrganisationFormProps = { diff --git a/src/features/agents/use-agent-update-check.ts b/src/features/agents/hooks/use-agent-update-check.ts similarity index 100% rename from src/features/agents/use-agent-update-check.ts rename to src/features/agents/hooks/use-agent-update-check.ts diff --git a/src/features/agents/agent-organizations.schema.ts b/src/features/agents/schemas/agent-organizations.schema.ts similarity index 100% rename from src/features/agents/agent-organizations.schema.ts rename to src/features/agents/schemas/agent-organizations.schema.ts diff --git a/src/features/agents/agents.schema.ts b/src/features/agents/schemas/agents.schema.ts similarity index 100% rename from src/features/agents/agents.schema.ts rename to src/features/agents/schemas/agents.schema.ts diff --git a/src/features/auth/forgot-password.action.ts b/src/features/auth/actions/forgot-password.action.ts similarity index 100% rename from src/features/auth/forgot-password.action.ts rename to src/features/auth/actions/forgot-password.action.ts diff --git a/src/features/auth/reset-password.action.ts b/src/features/auth/actions/reset-password.action.ts similarity index 100% rename from src/features/auth/reset-password.action.ts rename to src/features/auth/actions/reset-password.action.ts diff --git a/src/features/auth/auth-logo-section.tsx b/src/features/auth/components/auth-logo-section.tsx similarity index 100% rename from src/features/auth/auth-logo-section.tsx rename to src/features/auth/components/auth-logo-section.tsx diff --git a/src/features/auth/forgot-password-form.tsx b/src/features/auth/components/forgot-password-form.tsx similarity index 96% rename from src/features/auth/forgot-password-form.tsx rename to src/features/auth/components/forgot-password-form.tsx index 7b205954..881523fc 100644 --- a/src/features/auth/forgot-password-form.tsx +++ b/src/features/auth/components/forgot-password-form.tsx @@ -9,7 +9,7 @@ import { Form } from "@/components/ui/form"; import { requestPasswordReset } from "@/lib/auth/auth-client"; import { ButtonWithLoading } from "@/components/common/button-with-loading"; import Link from "next/link"; -import { ForgotPasswordSchema, ForgotPasswordType } from "./forgot-password-form.schema"; +import { ForgotPasswordSchema, ForgotPasswordType } from "../schemas/forgot-password-form.schema"; import { ArrowLeft } from "lucide-react"; export type ForgotPasswordFormProps = { diff --git a/src/features/auth/guard-form.tsx b/src/features/auth/components/guard-form.tsx similarity index 90% rename from src/features/auth/guard-form.tsx rename to src/features/auth/components/guard-form.tsx index 1cce2532..da081b3a 100644 --- a/src/features/auth/guard-form.tsx +++ b/src/features/auth/components/guard-form.tsx @@ -2,7 +2,7 @@ import { toast } from "sonner"; import { useRouter, useSearchParams } from "next/navigation"; -import TwoFactorForm from "@/features/profile/2fa-form"; +import TwoFactorForm from "@/features/profile/components/2fa-form"; export const GuardForm = () => { const router = useRouter(); diff --git a/src/features/auth/login-form.tsx b/src/features/auth/components/login-form.tsx similarity index 98% rename from src/features/auth/login-form.tsx rename to src/features/auth/components/login-form.tsx index f6629c4b..0b080357 100644 --- a/src/features/auth/login-form.tsx +++ b/src/features/auth/components/login-form.tsx @@ -19,7 +19,7 @@ import { PasswordInput } from "@/components/ui/password-input"; import { LoginSchema, type LoginType, -} from "@/features/auth/login-form.schema"; +} from "@/features/auth/schemas/login-form.schema"; import { ButtonWithLoading } from "@/components/common/button-with-loading"; import { signIn } from "@/lib/auth/auth-client"; diff --git a/src/features/auth/register-form.tsx b/src/features/auth/components/register-form.tsx similarity index 97% rename from src/features/auth/register-form.tsx rename to src/features/auth/components/register-form.tsx index 249d8b37..2054199a 100644 --- a/src/features/auth/register-form.tsx +++ b/src/features/auth/components/register-form.tsx @@ -14,10 +14,10 @@ import { import { RegisterSchema, RegisterType, -} from "@/features/auth/register-form.schema"; +} from "@/features/auth/schemas/register-form.schema"; import { signUp } from "@/lib/auth/auth-client"; import { useZodForm } from "@/components/ui/form"; -import { CardAuth } from "@/features/layout/card-auth"; +import { CardAuth } from "@/features/layout/components/card-auth"; import { FormField } from "@/components/ui/form"; import { FormItem } from "@/components/ui/form"; import { FormLabel } from "@/components/ui/form"; diff --git a/src/features/auth/reset-password-form.tsx b/src/features/auth/components/reset-password-form.tsx similarity index 97% rename from src/features/auth/reset-password-form.tsx rename to src/features/auth/components/reset-password-form.tsx index b8d17395..aed94a8c 100644 --- a/src/features/auth/reset-password-form.tsx +++ b/src/features/auth/components/reset-password-form.tsx @@ -7,7 +7,7 @@ import {FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/compone import {Form} from "@/components/ui/form"; import {ButtonWithLoading} from "@/components/common/button-with-loading"; import Link from "next/link"; -import {ResetPasswordSchema, ResetPasswordType} from "./reset-password-form.schema"; +import {ResetPasswordSchema, ResetPasswordType} from "../schemas/reset-password-form.schema"; import {PasswordStrengthInput} from "@/components/ui/password-input-indicator"; import {useRouter, useSearchParams} from "next/navigation"; import {ArrowLeft} from "lucide-react"; diff --git a/src/features/auth/components/social-buttons.tsx b/src/features/auth/components/social-buttons.tsx new file mode 100644 index 00000000..516a9c54 --- /dev/null +++ b/src/features/auth/components/social-buttons.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { authClient, passkey } from "@/lib/auth/auth-client"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Icon } from "@iconify/react"; +import { AuthProviderConfig } from "@/lib/auth/config"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; + +export function SocialAuthButtons({ + providers, +}: { + providers: AuthProviderConfig[]; +}) { + const socialProviders = providers.filter( + (p) => p.isActive && p.type !== "credential", + ); + const router = useRouter(); + + const [isLoading, setIsLoading] = useState(null); + + const handleSocialSignIn = async (provider: AuthProviderConfig) => { + setIsLoading(provider.id); + try { + let result; + if (provider.id === "passkey") { + result = await authClient.signIn.passkey({ + fetchOptions: { + onSuccess() { + router.push("/dashboard"); + }, + onError() { + toast.error( + "An error occurred during passkey authentication. Please try again.", + ); + }, + }, + }); + } else if (provider.type === "sso") { + result = await authClient.signIn.sso({ + providerId: provider.id, + providerType: "oidc", + callbackURL: "/dashboard", + }); + } else { + result = await authClient.signIn.social({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider: provider.id as any, + callbackURL: "/dashboard", + }); + } + + if (result?.error) { + toast.error( + "An error occurred while signing in with the provider. Please try again.", + ); + } else if (provider.id !== "passkey") { + toast.success("Redirecting to provider..."); + } + } catch (err) { + toast.error( + "An error occurred while signing in with the provider. Please try again.", + ); + } finally { + setIsLoading(null); + } + }; + + if (socialProviders.length === 0) return null; + + return ( +
+ {socialProviders.map((provider) => ( + + ))} +
+ ); +} diff --git a/src/features/auth/forgot-password-form.schema.ts b/src/features/auth/schemas/forgot-password-form.schema.ts similarity index 100% rename from src/features/auth/forgot-password-form.schema.ts rename to src/features/auth/schemas/forgot-password-form.schema.ts diff --git a/src/features/auth/login-form.schema.ts b/src/features/auth/schemas/login-form.schema.ts similarity index 100% rename from src/features/auth/login-form.schema.ts rename to src/features/auth/schemas/login-form.schema.ts diff --git a/src/features/auth/register-form.schema.ts b/src/features/auth/schemas/register-form.schema.ts similarity index 100% rename from src/features/auth/register-form.schema.ts rename to src/features/auth/schemas/register-form.schema.ts diff --git a/src/features/auth/reset-password-form.schema.ts b/src/features/auth/schemas/reset-password-form.schema.ts similarity index 100% rename from src/features/auth/reset-password-form.schema.ts rename to src/features/auth/schemas/reset-password-form.schema.ts diff --git a/src/features/auth/reset-password-page.schema.ts b/src/features/auth/schemas/reset-password-page.schema.ts similarity index 100% rename from src/features/auth/reset-password-page.schema.ts rename to src/features/auth/schemas/reset-password-page.schema.ts diff --git a/src/features/auth/social-buttons.tsx b/src/features/auth/social-buttons.tsx deleted file mode 100644 index 0294a9dd..00000000 --- a/src/features/auth/social-buttons.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { authClient, passkey } from "@/lib/auth/auth-client"; -import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; -import { Icon } from "@iconify/react"; -import { AuthProviderConfig } from "@/lib/auth/config"; -import Image from "next/image"; -import { useRouter } from "next/navigation"; - -export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) { - const socialProviders = providers.filter((p) => p.isActive && p.type !== "credential"); - const router = useRouter(); - - const [isLoading, setIsLoading] = useState(null); - - const handleSocialSignIn = async (provider: AuthProviderConfig) => { - setIsLoading(provider.id); - try { - let result; - if (provider.id === "passkey") { - result = await authClient.signIn.passkey({ - fetchOptions: { - onSuccess() { - router.push("/dashboard"); - }, - onError() { - toast.error("An error occurred during passkey authentication. Please try again."); - }, - }, - }); - } else if (provider.type === "sso") { - result = await authClient.signIn.sso({ - providerId: provider.id, - providerType: "oidc", - callbackURL: "/dashboard", - }); - console.log(result); - } else { - result = await authClient.signIn.social({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - provider: provider.id as any, - callbackURL: "/dashboard", - }); - } - - if (result?.error) { - toast.error("An error occurred while signing in with the provider. Please try again."); - } else if (provider.id !== "passkey") { - toast.success("Redirecting to provider..."); - } - } catch (err) { - toast.error("An error occurred while signing in with the provider. Please try again."); - } finally { - setIsLoading(null); - } - }; - - if (socialProviders.length === 0) return null; - - return ( -
- {socialProviders.map((provider) => ( - - ))} -
- ); -} diff --git a/src/features/channel/channel-card.tsx b/src/features/channel/channel-card.tsx deleted file mode 100644 index 5ee1cb92..00000000 --- a/src/features/channel/channel-card.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; - -import {Card} from "@/components/ui/card"; -import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; -import {Badge} from "@/components/ui/badge"; -import {OrganizationWithMembers} from "@/db/schema/03_organization"; -import {truncateWords} from "@/utils/text"; -import {useIsMobile} from "@/hooks/use-mobile"; -import {StorageChannelWith} from "@/db/schema/12_storage-channel"; -import { - EditChannelButton -} from "@/features/channel/channel-edit-button"; -import {ChannelKind, getChannelIcon} from "@/features/channel/channels-helpers"; -import { - DeleteChannelButton -} from "@/features/channel/channel-delete-button"; - -export type ChannelCardProps = { - data: NotificationChannelWith | StorageChannelWith; - organization?: OrganizationWithMembers; - organizations?: OrganizationWithMembers[]; - adminView?: boolean; - kind?: ChannelKind; - defaultStorageChannelId?: string | null | undefined - -}; - - -export const ChannelCard = (props: ChannelCardProps) => { - const {data, organization, kind, adminView, defaultStorageChannelId} = props; - const isMobile = useIsMobile() - - const isDefaultSystemStorage = defaultStorageChannelId === data.id; - - const isOwned = data.organizationId ? true : !organization; - const isLocalSystem = data.provider == "local"; - - return ( -
- -
-
- {getChannelIcon(data.provider)} -
-
-
-
-
-

{isMobile ? truncateWords(data.name, 2) : data.name}

- - {data.provider} - - {isDefaultSystemStorage && ( - - default - - )} -
-
- {kind && ( -
- {(isOwned) && ( - <> - - {!isLocalSystem && ( - - )} - - )} -
- )} - -
- ); -}; - - diff --git a/src/features/channel/channel-add-edit-modal.tsx b/src/features/channel/components/channel-add-edit-modal.tsx similarity index 97% rename from src/features/channel/channel-add-edit-modal.tsx rename to src/features/channel/components/channel-add-edit-modal.tsx index c031ca2a..4f8e1164 100644 --- a/src/features/channel/channel-add-edit-modal.tsx +++ b/src/features/channel/components/channel-add-edit-modal.tsx @@ -12,11 +12,11 @@ import {useIsMobile} from "@/hooks/use-mobile"; import {useEffect, useState} from "react"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; import {StorageChannelWith} from "@/db/schema/12_storage-channel"; -import {ChannelForm} from "@/features/channel/channel-form"; -import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/channels-helpers"; +import {ChannelForm} from "@/features/channel/components/channel-form"; +import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/components/channels-helpers"; import { ChannelOrganisationForm -} from "@/features/organizations/channels-organization-form"; +} from "@/features/organizations/components/channels-organization-form"; type ChannelAddModalProps = { diff --git a/src/features/channel/components/channel-card.tsx b/src/features/channel/components/channel-card.tsx new file mode 100644 index 00000000..414fbe91 --- /dev/null +++ b/src/features/channel/components/channel-card.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { NotificationChannelWith } from "@/db/schema/09_notification-channel"; +import { Badge } from "@/components/ui/badge"; +import { OrganizationWithMembers } from "@/db/schema/03_organization"; +import { truncateWords } from "@/utils/text"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { StorageChannelWith } from "@/db/schema/12_storage-channel"; +import { EditChannelButton } from "@/features/channel/components/channel-edit-button"; +import { + ChannelKind, + getChannelIcon, +} from "@/features/channel/components/channels-helpers"; +import { DeleteChannelButton } from "@/features/channel/components/channel-delete-button"; + +export type ChannelCardProps = { + data: NotificationChannelWith | StorageChannelWith; + organization?: OrganizationWithMembers; + organizations?: OrganizationWithMembers[]; + adminView?: boolean; + kind?: ChannelKind; + defaultStorageChannelId?: string | null | undefined; +}; + +export const ChannelCard = (props: ChannelCardProps) => { + const { data, organization, kind, defaultStorageChannelId } = props; + const isMobile = useIsMobile(); + + const isDefaultSystemStorage = defaultStorageChannelId === data.id; + + const isOwned = data.organizationId ? true : !organization; + const isLocalSystem = data.provider === "local"; + + return ( +
+ +
+
+ {getChannelIcon(data.provider)} +
+
+
+
+
+

+ {isMobile ? truncateWords(data.name, 2) : data.name} +

+ + {data.provider} + + {isDefaultSystemStorage && ( + + default + + )} +
+
+ {kind && ( +
+ {isOwned && ( + <> + + {!isLocalSystem && ( + + )} + + )} +
+ )} + +
+ ); +}; diff --git a/src/features/channel/channel-delete-button.tsx b/src/features/channel/components/channel-delete-button.tsx similarity index 93% rename from src/features/channel/channel-delete-button.tsx rename to src/features/channel/components/channel-delete-button.tsx index bb6b883a..bac951ba 100644 --- a/src/features/channel/channel-delete-button.tsx +++ b/src/features/channel/components/channel-delete-button.tsx @@ -5,12 +5,12 @@ import {useRouter} from "next/navigation"; import {Trash2} from "lucide-react"; import { removeNotificationChannelAction, -} from "@/features/channel/notifications/channel.action"; +} from "@/features/channel/components/notifications/channel.action"; import {toast} from "sonner"; -import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/channels-helpers"; +import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/components/channels-helpers"; import { removeStorageChannelAction -} from "@/features/channel/storages/channel.action"; +} from "@/features/channel/components/storages/channel.action"; export type DeleteChannelButtonProps = { channelId: string; diff --git a/src/features/channel/channel-edit-button.tsx b/src/features/channel/components/channel-edit-button.tsx similarity index 91% rename from src/features/channel/channel-edit-button.tsx rename to src/features/channel/components/channel-edit-button.tsx index 351043f6..37ece12d 100644 --- a/src/features/channel/channel-edit-button.tsx +++ b/src/features/channel/components/channel-edit-button.tsx @@ -7,14 +7,14 @@ import {toast} from "sonner"; import {useState} from "react"; import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {StorageChannelWith} from "@/db/schema/12_storage-channel"; -import {ChannelKind} from "@/features/channel/channels-helpers"; +import {ChannelKind} from "@/features/channel/components/channels-helpers"; import { updateNotificationChannelAction -} from "@/features/channel/notifications/channel.action"; -import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal"; +} from "@/features/channel/components/notifications/channel.action"; +import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal"; import { updateStorageChannelAction -} from "@/features/channel/storages/channel.action"; +} from "@/features/channel/components/storages/channel.action"; export type EditChannelButtonProps = { channel: NotificationChannelWith | StorageChannelWith; diff --git a/src/features/channel/channel-form.tsx b/src/features/channel/components/channel-form.tsx similarity index 95% rename from src/features/channel/channel-form.tsx rename to src/features/channel/components/channel-form.tsx index b47ab76e..979130b7 100644 --- a/src/features/channel/channel-form.tsx +++ b/src/features/channel/components/channel-form.tsx @@ -8,7 +8,7 @@ import {Input} from "@/components/ui/input"; import { addStorageChannelAction, updateStorageChannelAction -} from "@/features/channel/storages/channel.action"; +} from "@/features/channel/components/storages/channel.action"; import {toast} from "sonner"; import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {Button} from "@/components/ui/button"; @@ -20,19 +20,19 @@ import {ArrowLeft} from "lucide-react"; import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import { NotificationChannelFormSchema, NotificationChannelFormType, StorageChannelFormSchema, StorageChannelFormType -} from "@/features/channel/channel-form.schema"; +} from "@/features/channel/schemas/channel-form.schema"; import { ChannelKind, renderChannelForm -} from "@/features/channel/channels-helpers"; -import {storageProviders} from "@/features/channel/channels-storage-helper"; -import {notificationProviders} from "@/features/channel/channels-notification-helper"; +} from "@/features/channel/components/channels-helpers"; +import {storageProviders} from "@/features/channel/components/channels-storage-helper"; +import {notificationProviders} from "@/features/channel/components/channels-notification-helper"; import { ChannelTestButton -} from "@/features/channel/channel-test-button"; +} from "@/features/channel/components/channel-test-button"; import { addNotificationChannelAction, updateNotificationChannelAction -} from "@/features/channel/notifications/channel.action"; +} from "@/features/channel/components/notifications/channel.action"; type NotifierFormProps = { diff --git a/src/features/channel/channel-test-button.tsx b/src/features/channel/components/channel-test-button.tsx similarity index 89% rename from src/features/channel/channel-test-button.tsx rename to src/features/channel/components/channel-test-button.tsx index fc57d8b2..549b8d43 100644 --- a/src/features/channel/channel-test-button.tsx +++ b/src/features/channel/components/channel-test-button.tsx @@ -6,14 +6,14 @@ import {toast} from "sonner"; import {Button} from "@/components/ui/button"; import {NotificationChannel} from "@/db/schema/09_notification-channel"; -import {dispatchNotification} from "@/features/notifications/notifications.dispatch"; -import {EventPayload} from "@/features/notifications/notifications.types"; +import {dispatchNotification} from "@/features/notifications/utils/notifications.dispatch"; +import {EventPayload} from "@/features/notifications/types"; import {useIsMobile} from "@/hooks/use-mobile"; import {cn} from "@/lib/utils"; import {StorageChannel} from "@/db/schema/12_storage-channel"; -import {ChannelKind} from "@/features/channel/channels-helpers"; -import type {StorageInput} from "@/features/storages/storages.types"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; +import {ChannelKind} from "@/features/channel/components/channels-helpers"; +import type {StorageInput} from "@/features/storages/types"; +import {dispatchStorage} from "@/features/storages/utils/storages.dispatch"; type NotifierTestChannelButtonProps = { diff --git a/src/features/channel/channels-helpers.tsx b/src/features/channel/components/channels-helpers.tsx similarity index 73% rename from src/features/channel/channels-helpers.tsx rename to src/features/channel/components/channels-helpers.tsx index 5d1f6b6b..bfee94ca 100644 --- a/src/features/channel/channels-helpers.tsx +++ b/src/features/channel/components/channels-helpers.tsx @@ -1,49 +1,49 @@ import {UseFormReturn} from "react-hook-form"; import { NotifierSmtpForm -} from "@/features/channel/notifications/smtp.form"; +} from "@/features/channel/components/notifications/smtp.form"; import { NotifierSlackForm -} from "@/features/channel/notifications/slack.form"; +} from "@/features/channel/components/notifications/slack.form"; import { NotifierDiscordForm -} from "@/features/channel/notifications/discord.form"; +} from "@/features/channel/components/notifications/discord.form"; import { NotifierTelegramForm -} from "@/features/channel/notifications/telegram.form"; +} from "@/features/channel/components/notifications/telegram.form"; import { NotifierGotifyForm -} from "@/features/channel/notifications/gotify.form"; +} from "@/features/channel/components/notifications/gotify.form"; import { NotifierNtfyForm -} from "@/features/channel/notifications/ntfy.form"; +} from "@/features/channel/components/notifications/ntfy.form"; import { NotifierWebhookForm -} from "@/features/channel/notifications/webhook.form"; +} from "@/features/channel/components/notifications/webhook.form"; import { NotifierNextcloudForm -} from "@/features/channel/notifications/nextcloud.form"; +} from "@/features/channel/components/notifications/nextcloud.form"; import { NotifierPushoverForm -} from "@/features/channel/notifications/pushover.form"; +} from "@/features/channel/components/notifications/pushover.form"; import { notificationProviders, -} from "@/features/channel/channels-notification-helper"; +} from "@/features/channel/components/channels-notification-helper"; import { NotifierTeamsForm -} from "@/features/channel/notifications/teams.form"; -import {storageProviders} from "@/features/channel/channels-storage-helper"; +} from "@/features/channel/components/notifications/teams.form"; +import {storageProviders} from "@/features/channel/components/channels-storage-helper"; import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react"; import {LucideProps} from "lucide-react"; import { StorageS3Form -} from "@/features/channel/storages/s3.form"; +} from "@/features/channel/components/storages/s3.form"; import { StorageGoogleDriveForm -} from "@/features/channel/storages/google-drive/google-drive.form"; +} from "@/features/channel/components/storages/google-drive/google-drive.form"; import { StorageBlobForm -} from "@/features/channel/storages/az-blob.form"; +} from "@/features/channel/components/storages/az-blob.form"; export type ChannelKind = "notification" | "storage"; diff --git a/src/features/channel/channels-notification-helper.tsx b/src/features/channel/components/channels-notification-helper.tsx similarity index 99% rename from src/features/channel/channels-notification-helper.tsx rename to src/features/channel/components/channels-notification-helper.tsx index d903a604..29181582 100644 --- a/src/features/channel/channels-notification-helper.tsx +++ b/src/features/channel/components/channels-notification-helper.tsx @@ -1,6 +1,6 @@ import {Mail} from "lucide-react"; import type { SVGProps } from 'react'; -import {ProviderIconTypes} from "@/features/channel/channels-helpers"; +import {ProviderIconTypes} from "@/features/channel/components/channels-helpers"; diff --git a/src/features/channel/channels-section.tsx b/src/features/channel/components/channels-section.tsx similarity index 89% rename from src/features/channel/channels-section.tsx rename to src/features/channel/components/channels-section.tsx index fdb00c2a..74e4b695 100644 --- a/src/features/channel/channels-section.tsx +++ b/src/features/channel/components/channels-section.tsx @@ -5,12 +5,12 @@ import { useState } from "react"; import { EmptyStatePlaceholder } from "@/components/common/empty-state-placeholder"; import { OrganizationWithMembers } from "@/db/schema/03_organization"; import { StorageChannelWith } from "@/db/schema/12_storage-channel"; -import { ChannelCard } from "@/features/channel/channel-card"; -import { ChannelAddEditModal } from "@/features/channel/channel-add-edit-modal"; +import { ChannelCard } from "@/features/channel/components/channel-card"; +import { ChannelAddEditModal } from "@/features/channel/components/channel-add-edit-modal"; import { ChannelKind, getChannelTextBasedOnKind, -} from "@/features/channel/channels-helpers"; +} from "@/features/channel/components/channels-helpers"; type ChannelsSectionProps = { channels: NotificationChannelWith[] | StorageChannelWith[]; diff --git a/src/features/channel/channels-storage-helper.tsx b/src/features/channel/components/channels-storage-helper.tsx similarity index 98% rename from src/features/channel/channels-storage-helper.tsx rename to src/features/channel/components/channels-storage-helper.tsx index 0c151f24..9f1a71ec 100644 --- a/src/features/channel/channels-storage-helper.tsx +++ b/src/features/channel/components/channels-storage-helper.tsx @@ -1,6 +1,6 @@ import {Server} from "lucide-react"; import type {SVGProps} from "react"; -import {ProviderIconTypes} from "@/features/channel/channels-helpers"; +import {ProviderIconTypes} from "@/features/channel/components/channels-helpers"; export const storageProviders: ProviderIconTypes[] = [ {value: "local", label: "Local", icon: Server}, diff --git a/src/features/channel/components/notifications/channel.action.ts b/src/features/channel/components/notifications/channel.action.ts new file mode 100644 index 00000000..d701703b --- /dev/null +++ b/src/features/channel/components/notifications/channel.action.ts @@ -0,0 +1,200 @@ +"use server"; + +import { z } from "zod"; +import { ServerActionResult } from "@/types/action-type"; +import * as drizzleDb from "@/db"; +import { userAction } from "@/lib/safe-actions/actions"; +import { NotificationChannel } from "@/db/schema/09_notification-channel"; +import { db } from "@/db"; +import { and, eq } from "drizzle-orm"; +import { withUpdatedAt } from "@/db/utils"; +import { NotificationChannelFormSchema } from "@/features/channel/schemas/channel-form.schema"; + +export const addNotificationChannelAction = userAction + .schema( + z.object({ + organizationId: z.string().optional(), + data: NotificationChannelFormSchema, + }), + ) + .action( + async ({ + parsedInput, + }): Promise> => { + const { organizationId, data } = parsedInput; + try { + const [channel] = await db + .insert(drizzleDb.schemas.notificationChannel) + .values({ + provider: data.provider, + name: data.name, + config: data.config, + enabled: data.enabled ?? true, + organizationId: organizationId ?? null, + }) + .returning(); + + if (organizationId) { + await db + .insert(drizzleDb.schemas.organizationNotificationChannel) + .values({ + organizationId, + notificationChannelId: channel.id, + }); + } + + return { + success: true, + value: { + ...channel, + config: channel.config as JSON, + }, + actionSuccess: { + message: "Notification channel has been successfully created.", + messageParams: { notificationChannelId: channel.id }, + }, + }; + } catch (_error) { + const error = _error; + return { + success: false, + actionError: { + message: "Failed to create notification channel.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: { notificationChannelId: "" }, + }, + }; + } + }, + ); + +export const removeNotificationChannelAction = userAction + .schema( + z.object({ + organizationId: z.string().optional(), + notificationChannelId: z.string(), + }), + ) + .action( + async ({ + parsedInput, + }): Promise> => { + const { organizationId, notificationChannelId } = parsedInput; + + try { + if (organizationId) { + await db + .delete(drizzleDb.schemas.organizationNotificationChannel) + .where( + and( + eq( + drizzleDb.schemas.organizationNotificationChannel + .organizationId, + organizationId, + ), + eq( + drizzleDb.schemas.organizationNotificationChannel + .notificationChannelId, + notificationChannelId, + ), + ), + ); + } + + const [deletedChannel] = await db + .delete(drizzleDb.schemas.notificationChannel) + .where( + eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId), + ) + .returning(); + + if (!deletedChannel) { + return { + success: false, + actionError: { + message: "Notification channel not found.", + status: 404, + messageParams: { notificationChannelId: notificationChannelId }, + }, + }; + } + + return { + success: true, + value: { + ...deletedChannel, + config: deletedChannel.config as JSON, + }, + actionSuccess: { + message: "Notification channel has been successfully removed.", + messageParams: { notificationChannelId: notificationChannelId }, + }, + }; + } catch (_error) { + const error = _error; + return { + success: false, + actionError: { + message: "Failed to remove notification channel.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: { notificationChannelId: notificationChannelId }, + }, + }; + } + }, + ); + +export const updateNotificationChannelAction = userAction + .schema( + z.object({ + id: z.string(), + data: NotificationChannelFormSchema, + }), + ) + .action( + async ({ + parsedInput, + }): Promise> => { + const { id, data } = parsedInput; + + try { + const [channel] = await db + .update(drizzleDb.schemas.notificationChannel) + .set( + withUpdatedAt({ + provider: data.provider, + name: data.name, + config: data.config, + enabled: data.enabled ?? true, + }), + ) + .where(eq(drizzleDb.schemas.notificationChannel.id, id)) + .returning(); + + return { + success: true, + value: { + ...channel, + config: channel.config as JSON, + }, + actionSuccess: { + message: `Notification channel "${channel.name}" has been successfully updated.`, + messageParams: { id: channel.id }, + }, + }; + } catch (_error) { + const error = _error; + return { + success: false, + actionError: { + message: "Failed to update notification channel.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: { id: "" }, + }, + }; + } + }, + ); diff --git a/src/features/channel/notifications/discord.form.tsx b/src/features/channel/components/notifications/discord.form.tsx similarity index 100% rename from src/features/channel/notifications/discord.form.tsx rename to src/features/channel/components/notifications/discord.form.tsx diff --git a/src/features/channel/notifications/discord.schema.ts b/src/features/channel/components/notifications/discord.schema.ts similarity index 100% rename from src/features/channel/notifications/discord.schema.ts rename to src/features/channel/components/notifications/discord.schema.ts diff --git a/src/features/channel/notifications/discord.ts b/src/features/channel/components/notifications/discord.ts similarity index 97% rename from src/features/channel/notifications/discord.ts rename to src/features/channel/components/notifications/discord.ts index fa6935ac..46d47953 100644 --- a/src/features/channel/notifications/discord.ts +++ b/src/features/channel/components/notifications/discord.ts @@ -1,4 +1,4 @@ -import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types'; +import type {EventPayload, DispatchResult} from '@/features/notifications/types'; export async function sendDiscord( config: { discordWebhook: string }, diff --git a/src/features/channel/notifications/gotify.form.tsx b/src/features/channel/components/notifications/gotify.form.tsx similarity index 100% rename from src/features/channel/notifications/gotify.form.tsx rename to src/features/channel/components/notifications/gotify.form.tsx diff --git a/src/features/channel/notifications/gotify.schema.ts b/src/features/channel/components/notifications/gotify.schema.ts similarity index 100% rename from src/features/channel/notifications/gotify.schema.ts rename to src/features/channel/components/notifications/gotify.schema.ts diff --git a/src/features/channel/notifications/gotify.ts b/src/features/channel/components/notifications/gotify.ts similarity index 97% rename from src/features/channel/notifications/gotify.ts rename to src/features/channel/components/notifications/gotify.ts index 1bf77e9c..80b500af 100644 --- a/src/features/channel/notifications/gotify.ts +++ b/src/features/channel/components/notifications/gotify.ts @@ -1,4 +1,4 @@ -import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types'; +import type {EventPayload, DispatchResult} from '@/features/notifications/types'; export async function sendGotify( config: { gotifyServerUrl: string; gotifyAppToken: string }, diff --git a/src/features/channel/notifications/index.ts b/src/features/channel/components/notifications/index.ts similarity index 62% rename from src/features/channel/notifications/index.ts rename to src/features/channel/components/notifications/index.ts index 08e67961..c7df82d9 100644 --- a/src/features/channel/notifications/index.ts +++ b/src/features/channel/components/notifications/index.ts @@ -1,15 +1,15 @@ "use server" -import type {ProviderKind, EventPayload, DispatchResult} from '@/features/notifications/notifications.types'; +import type {ProviderKind, EventPayload, DispatchResult} from '@/features/notifications/types'; import {sendSlack} from './slack'; import {sendSmtp} from './smtp'; -import {sendDiscord} from "@/features/channel/notifications/discord"; -import {sendTelegram} from "@/features/channel/notifications/telegram"; -import {sendGotify} from "@/features/channel/notifications/gotify"; -import {sendNtfy} from "@/features/channel/notifications/ntfy"; -import {sendWebhook} from "@/features/channel/notifications/webhook"; -import {sendNextcloud} from "@/features/channel/notifications/nextcloud"; -import {sendPushover} from "@/features/channel/notifications/pushover"; -import {sendTeams} from "@/features/channel/notifications/teams" +import {sendDiscord} from "@/features/channel/components/notifications/discord"; +import {sendTelegram} from "@/features/channel/components/notifications/telegram"; +import {sendGotify} from "@/features/channel/components/notifications/gotify"; +import {sendNtfy} from "@/features/channel/components/notifications/ntfy"; +import {sendWebhook} from "@/features/channel/components/notifications/webhook"; +import {sendNextcloud} from "@/features/channel/components/notifications/nextcloud"; +import {sendPushover} from "@/features/channel/components/notifications/pushover"; +import {sendTeams} from "@/features/channel/components/notifications/teams" const handlers: Record< ProviderKind, diff --git a/src/features/channel/notifications/nextcloud.form.tsx b/src/features/channel/components/notifications/nextcloud.form.tsx similarity index 100% rename from src/features/channel/notifications/nextcloud.form.tsx rename to src/features/channel/components/notifications/nextcloud.form.tsx diff --git a/src/features/channel/notifications/nextcloud.schema.ts b/src/features/channel/components/notifications/nextcloud.schema.ts similarity index 100% rename from src/features/channel/notifications/nextcloud.schema.ts rename to src/features/channel/components/notifications/nextcloud.schema.ts diff --git a/src/features/channel/notifications/nextcloud.ts b/src/features/channel/components/notifications/nextcloud.ts similarity index 98% rename from src/features/channel/notifications/nextcloud.ts rename to src/features/channel/components/notifications/nextcloud.ts index 9ae8233c..0c248f70 100644 --- a/src/features/channel/notifications/nextcloud.ts +++ b/src/features/channel/components/notifications/nextcloud.ts @@ -1,5 +1,5 @@ import { createHmac, randomBytes } from "crypto"; -import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types'; +import type { EventPayload, DispatchResult } from '@/features/notifications/types'; type NextcloudConfig = { nextcloudUrl: string; diff --git a/src/features/channel/notifications/ntfy.form.tsx b/src/features/channel/components/notifications/ntfy.form.tsx similarity index 100% rename from src/features/channel/notifications/ntfy.form.tsx rename to src/features/channel/components/notifications/ntfy.form.tsx diff --git a/src/features/channel/notifications/ntfy.schema.ts b/src/features/channel/components/notifications/ntfy.schema.ts similarity index 100% rename from src/features/channel/notifications/ntfy.schema.ts rename to src/features/channel/components/notifications/ntfy.schema.ts diff --git a/src/features/channel/notifications/ntfy.ts b/src/features/channel/components/notifications/ntfy.ts similarity index 98% rename from src/features/channel/notifications/ntfy.ts rename to src/features/channel/components/notifications/ntfy.ts index 8f3f742e..28c35bac 100644 --- a/src/features/channel/notifications/ntfy.ts +++ b/src/features/channel/components/notifications/ntfy.ts @@ -1,4 +1,4 @@ -import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types'; +import type {EventPayload, DispatchResult} from '@/features/notifications/types'; const getPriority = (level?: string): number => { switch (level) { diff --git a/src/features/channel/notifications/pushover.form.tsx b/src/features/channel/components/notifications/pushover.form.tsx similarity index 97% rename from src/features/channel/notifications/pushover.form.tsx rename to src/features/channel/components/notifications/pushover.form.tsx index 36327652..49416330 100644 --- a/src/features/channel/notifications/pushover.form.tsx +++ b/src/features/channel/components/notifications/pushover.form.tsx @@ -4,7 +4,7 @@ import {Input} from "@/components/ui/input"; import {Separator} from "@/components/ui/separator"; import {PasswordInput} from "@/components/ui/password-input"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; -import {PUSHOVER_PRIORITIES} from "@/features/channel/notifications/pushover.schema"; +import {PUSHOVER_PRIORITIES} from "@/features/channel/components/notifications/pushover.schema"; type NotifierPushoverFormProps = { form: UseFormReturn; diff --git a/src/features/channel/notifications/pushover.schema.ts b/src/features/channel/components/notifications/pushover.schema.ts similarity index 100% rename from src/features/channel/notifications/pushover.schema.ts rename to src/features/channel/components/notifications/pushover.schema.ts diff --git a/src/features/channel/notifications/pushover.ts b/src/features/channel/components/notifications/pushover.ts similarity index 98% rename from src/features/channel/notifications/pushover.ts rename to src/features/channel/components/notifications/pushover.ts index 42dbd6d5..1da3fec1 100644 --- a/src/features/channel/notifications/pushover.ts +++ b/src/features/channel/components/notifications/pushover.ts @@ -1,4 +1,4 @@ -import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types'; +import type {EventPayload, DispatchResult} from '@/features/notifications/types'; export async function sendPushover( config: { diff --git a/src/features/channel/notifications/slack.form.tsx b/src/features/channel/components/notifications/slack.form.tsx similarity index 100% rename from src/features/channel/notifications/slack.form.tsx rename to src/features/channel/components/notifications/slack.form.tsx diff --git a/src/features/channel/notifications/slack.schema.ts b/src/features/channel/components/notifications/slack.schema.ts similarity index 100% rename from src/features/channel/notifications/slack.schema.ts rename to src/features/channel/components/notifications/slack.schema.ts diff --git a/src/features/channel/notifications/slack.ts b/src/features/channel/components/notifications/slack.ts similarity index 97% rename from src/features/channel/notifications/slack.ts rename to src/features/channel/components/notifications/slack.ts index bb8bc8c8..3beceb3d 100644 --- a/src/features/channel/notifications/slack.ts +++ b/src/features/channel/components/notifications/slack.ts @@ -1,4 +1,4 @@ -import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types'; +import type {EventPayload, DispatchResult} from '@/features/notifications/types'; export async function sendSlack( config: { slackWebhook: string }, diff --git a/src/features/channel/notifications/smtp.form.tsx b/src/features/channel/components/notifications/smtp.form.tsx similarity index 100% rename from src/features/channel/notifications/smtp.form.tsx rename to src/features/channel/components/notifications/smtp.form.tsx diff --git a/src/features/channel/notifications/smtp.schema.ts b/src/features/channel/components/notifications/smtp.schema.ts similarity index 100% rename from src/features/channel/notifications/smtp.schema.ts rename to src/features/channel/components/notifications/smtp.schema.ts diff --git a/src/features/channel/notifications/smtp.ts b/src/features/channel/components/notifications/smtp.ts similarity index 97% rename from src/features/channel/notifications/smtp.ts rename to src/features/channel/components/notifications/smtp.ts index bbc25ecc..b7f58dd7 100644 --- a/src/features/channel/notifications/smtp.ts +++ b/src/features/channel/components/notifications/smtp.ts @@ -1,7 +1,7 @@ "use server"; import { render } from "@react-email/render"; import nodemailer from "nodemailer"; -import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types'; +import type { EventPayload, DispatchResult } from '@/features/notifications/types'; import EmailNotification from "@/components/emails/email-notification"; export async function sendSmtp( diff --git a/src/features/channel/notifications/teams.form.tsx b/src/features/channel/components/notifications/teams.form.tsx similarity index 100% rename from src/features/channel/notifications/teams.form.tsx rename to src/features/channel/components/notifications/teams.form.tsx diff --git a/src/features/channel/notifications/teams.schema.ts b/src/features/channel/components/notifications/teams.schema.ts similarity index 100% rename from src/features/channel/notifications/teams.schema.ts rename to src/features/channel/components/notifications/teams.schema.ts diff --git a/src/features/channel/notifications/teams.ts b/src/features/channel/components/notifications/teams.ts similarity index 98% rename from src/features/channel/notifications/teams.ts rename to src/features/channel/components/notifications/teams.ts index f1106678..ed6910cb 100644 --- a/src/features/channel/notifications/teams.ts +++ b/src/features/channel/components/notifications/teams.ts @@ -1,4 +1,4 @@ -import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types'; +import type { EventPayload, DispatchResult } from '@/features/notifications/types'; diff --git a/src/features/channel/notifications/telegram.form.tsx b/src/features/channel/components/notifications/telegram.form.tsx similarity index 100% rename from src/features/channel/notifications/telegram.form.tsx rename to src/features/channel/components/notifications/telegram.form.tsx diff --git a/src/features/channel/notifications/telegram.schema.ts b/src/features/channel/components/notifications/telegram.schema.ts similarity index 100% rename from src/features/channel/notifications/telegram.schema.ts rename to src/features/channel/components/notifications/telegram.schema.ts diff --git a/src/features/channel/notifications/telegram.ts b/src/features/channel/components/notifications/telegram.ts similarity index 97% rename from src/features/channel/notifications/telegram.ts rename to src/features/channel/components/notifications/telegram.ts index f6115558..2337fa4a 100644 --- a/src/features/channel/notifications/telegram.ts +++ b/src/features/channel/components/notifications/telegram.ts @@ -1,4 +1,4 @@ -import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types'; +import type { EventPayload, DispatchResult } from '@/features/notifications/types'; export async function sendTelegram( config: { diff --git a/src/features/channel/notifications/webhook.form.tsx b/src/features/channel/components/notifications/webhook.form.tsx similarity index 100% rename from src/features/channel/notifications/webhook.form.tsx rename to src/features/channel/components/notifications/webhook.form.tsx diff --git a/src/features/channel/notifications/webhook.schema.ts b/src/features/channel/components/notifications/webhook.schema.ts similarity index 100% rename from src/features/channel/notifications/webhook.schema.ts rename to src/features/channel/components/notifications/webhook.schema.ts diff --git a/src/features/channel/notifications/webhook.ts b/src/features/channel/components/notifications/webhook.ts similarity index 97% rename from src/features/channel/notifications/webhook.ts rename to src/features/channel/components/notifications/webhook.ts index 43dbba71..db0f3b5b 100644 --- a/src/features/channel/notifications/webhook.ts +++ b/src/features/channel/components/notifications/webhook.ts @@ -1,4 +1,4 @@ -import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types'; +import type { EventPayload, DispatchResult } from '@/features/notifications/types'; type WebhookConfig = { webhookUrl: string; diff --git a/src/features/channel/storages/az-blob.form.tsx b/src/features/channel/components/storages/az-blob.form.tsx similarity index 100% rename from src/features/channel/storages/az-blob.form.tsx rename to src/features/channel/components/storages/az-blob.form.tsx diff --git a/src/features/channel/storages/az-blob.schema.ts b/src/features/channel/components/storages/az-blob.schema.ts similarity index 100% rename from src/features/channel/storages/az-blob.schema.ts rename to src/features/channel/components/storages/az-blob.schema.ts diff --git a/src/features/channel/storages/az-blob.ts b/src/features/channel/components/storages/az-blob.ts similarity index 99% rename from src/features/channel/storages/az-blob.ts rename to src/features/channel/components/storages/az-blob.ts index 0ed81ee6..ebcbb28e 100644 --- a/src/features/channel/storages/az-blob.ts +++ b/src/features/channel/components/storages/az-blob.ts @@ -6,7 +6,7 @@ import { StorageMetaData, StorageResult, StorageUploadInput -} from '@/features/storages/storages.types'; +} from '@/features/storages/types'; import {Readable} from "node:stream"; type BlobConfig = { diff --git a/src/features/channel/components/storages/channel.action.ts b/src/features/channel/components/storages/channel.action.ts new file mode 100644 index 00000000..e5239554 --- /dev/null +++ b/src/features/channel/components/storages/channel.action.ts @@ -0,0 +1,189 @@ +"use server"; + +import { z } from "zod"; +import { ServerActionResult } from "@/types/action-type"; +import * as drizzleDb from "@/db"; +import { userAction } from "@/lib/safe-actions/actions"; +import { db } from "@/db"; +import { and, eq } from "drizzle-orm"; +import { withUpdatedAt } from "@/db/utils"; +import { StorageChannelFormSchema } from "@/features/channel/schemas/channel-form.schema"; +import { StorageChannel } from "@/db/schema/12_storage-channel"; + +export const addStorageChannelAction = userAction + .schema( + z.object({ + organizationId: z.string().optional(), + data: StorageChannelFormSchema, + }), + ) + .action( + async ({ parsedInput }): Promise> => { + const { organizationId, data } = parsedInput; + + try { + const [channel] = await db + .insert(drizzleDb.schemas.storageChannel) + .values({ + provider: data.provider, + name: data.name, + config: data.config, + enabled: data.enabled ?? true, + organizationId: organizationId ?? null, + }) + .returning(); + + if (organizationId) { + await db.insert(drizzleDb.schemas.organizationStorageChannel).values({ + organizationId, + storageChannelId: channel.id, + }); + } + + return { + success: true, + value: { + ...channel, + config: channel.config as JSON, + }, + actionSuccess: { + message: "Storage channel has been successfully created.", + messageParams: { id: channel.id }, + }, + }; + } catch (_error) { + const error = _error; + return { + success: false, + actionError: { + message: "Failed to create storage channel.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: { id: "" }, + }, + }; + } + }, + ); + +export const removeStorageChannelAction = userAction + .schema( + z.object({ + organizationId: z.string().optional(), + id: z.string(), + }), + ) + .action( + async ({ parsedInput }): Promise> => { + const { organizationId, id } = parsedInput; + + try { + if (organizationId) { + await db + .delete(drizzleDb.schemas.organizationStorageChannel) + .where( + and( + eq( + drizzleDb.schemas.organizationStorageChannel.organizationId, + organizationId, + ), + eq( + drizzleDb.schemas.organizationStorageChannel.storageChannelId, + id, + ), + ), + ); + } + + const [deletedChannel] = await db + .delete(drizzleDb.schemas.storageChannel) + .where(eq(drizzleDb.schemas.storageChannel.id, id)) + .returning(); + + if (!deletedChannel) { + return { + success: false, + actionError: { + message: "Storage channel not found.", + status: 404, + messageParams: { id: id }, + }, + }; + } + + return { + success: true, + value: { + ...deletedChannel, + config: deletedChannel.config as JSON, + }, + actionSuccess: { + message: "Storage channel has been successfully removed.", + messageParams: { id: id }, + }, + }; + } catch (_error) { + const error = _error; + return { + success: false, + actionError: { + message: "Failed to remove storage channel.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: { id: id }, + }, + }; + } + }, + ); + +export const updateStorageChannelAction = userAction + .schema( + z.object({ + id: z.string(), + data: StorageChannelFormSchema, + }), + ) + .action( + async ({ parsedInput }): Promise> => { + const { id, data } = parsedInput; + + try { + const [channel] = await db + .update(drizzleDb.schemas.storageChannel) + .set( + withUpdatedAt({ + provider: data.provider, + name: data.name, + config: data.config, + enabled: data.enabled ?? true, + }), + ) + .where(eq(drizzleDb.schemas.storageChannel.id, id)) + .returning(); + + return { + success: true, + value: { + ...channel, + config: channel.config as JSON, + }, + actionSuccess: { + message: `Storage channel "${channel.name}" has been successfully updated.`, + messageParams: { id: channel.id }, + }, + }; + } catch (_error) { + const error = _error; + return { + success: false, + actionError: { + message: "Failed to update storage channel.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: { id: "" }, + }, + }; + } + }, + ); diff --git a/src/features/channel/storages/google-drive/google-drive-refresh.action.ts b/src/features/channel/components/storages/google-drive/google-drive-refresh.action.ts similarity index 100% rename from src/features/channel/storages/google-drive/google-drive-refresh.action.ts rename to src/features/channel/components/storages/google-drive/google-drive-refresh.action.ts diff --git a/src/features/channel/storages/google-drive/google-drive.form.tsx b/src/features/channel/components/storages/google-drive/google-drive.form.tsx similarity index 98% rename from src/features/channel/storages/google-drive/google-drive.form.tsx rename to src/features/channel/components/storages/google-drive/google-drive.form.tsx index e2606eff..fbbf95fd 100644 --- a/src/features/channel/storages/google-drive/google-drive.form.tsx +++ b/src/features/channel/components/storages/google-drive/google-drive.form.tsx @@ -8,7 +8,7 @@ import {Button} from "@/components/ui/button"; import {PasswordInput} from "@/components/ui/password-input"; import { googleDriveRefreshTokenAction -} from "@/features/channel/storages/google-drive/google-drive-refresh.action"; +} from "@/features/channel/components/storages/google-drive/google-drive-refresh.action"; import {toast} from "sonner"; type StorageGoogleDriveFormProps = { diff --git a/src/features/channel/storages/google-drive/google-drive.schema.ts b/src/features/channel/components/storages/google-drive/google-drive.schema.ts similarity index 100% rename from src/features/channel/storages/google-drive/google-drive.schema.ts rename to src/features/channel/components/storages/google-drive/google-drive.schema.ts diff --git a/src/features/channel/storages/google-drive/helpers.ts b/src/features/channel/components/storages/google-drive/helpers.ts similarity index 97% rename from src/features/channel/storages/google-drive/helpers.ts rename to src/features/channel/components/storages/google-drive/helpers.ts index e6178406..e11c93f1 100644 --- a/src/features/channel/storages/google-drive/helpers.ts +++ b/src/features/channel/components/storages/google-drive/helpers.ts @@ -1,5 +1,5 @@ import {drive_v3, google} from "googleapis"; -import {GoogleDriveConfig} from "@/features/channel/storages/google-drive/types"; +import {GoogleDriveConfig} from "@/features/channel/components/storages/google-drive/types"; import Drive = drive_v3.Drive; import {getServerUrl} from "@/utils/get-server-url"; diff --git a/src/features/channel/storages/google-drive/index.ts b/src/features/channel/components/storages/google-drive/index.ts similarity index 95% rename from src/features/channel/storages/google-drive/index.ts rename to src/features/channel/components/storages/google-drive/index.ts index 7f7fa353..a575d8a7 100644 --- a/src/features/channel/storages/google-drive/index.ts +++ b/src/features/channel/components/storages/google-drive/index.ts @@ -6,15 +6,15 @@ import { StorageMetaData, StorageResult, StorageUploadInput -} from '@/features/storages/storages.types'; -import {GoogleDriveConfig} from "@/features/channel/storages/google-drive/types"; +} from '@/features/storages/types'; +import {GoogleDriveConfig} from "@/features/channel/components/storages/google-drive/types"; import { ensureFolderPath, findFileByName, getGoogleDriveClient, resolveFilePath -} from "@/features/channel/storages/google-drive/helpers"; +} from "@/features/channel/components/storages/google-drive/helpers"; import {Readable} from "node:stream"; -import {generateFileUrl} from "@/features/storages/storages.helpers"; +import {generateFileUrl} from "@/features/storages/utils/storages.helpers"; export async function uploadGoogleDrive( config: GoogleDriveConfig, diff --git a/src/features/channel/storages/google-drive/types.ts b/src/features/channel/components/storages/google-drive/types.ts similarity index 100% rename from src/features/channel/storages/google-drive/types.ts rename to src/features/channel/components/storages/google-drive/types.ts diff --git a/src/features/channel/storages/index.ts b/src/features/channel/components/storages/index.ts similarity index 92% rename from src/features/channel/storages/index.ts rename to src/features/channel/components/storages/index.ts index 851b6bf2..bb9d196f 100644 --- a/src/features/channel/storages/index.ts +++ b/src/features/channel/components/storages/index.ts @@ -2,18 +2,18 @@ import { StorageProviderKind, StorageInput, StorageResult, -} from '@/features/storages/storages.types'; +} from '@/features/storages/types'; import {uploadLocal, getLocal, deleteLocal, pingLocal, copyLocal} from './local'; -import {copyS3, deleteS3, getS3, pingS3, uploadS3} from "@/features/channel/storages/s3"; -import {copyBlob, deleteBlob, getBlob, pingBlob, uploadBlob} from "@/features/channel/storages/az-blob"; +import {copyS3, deleteS3, getS3, pingS3, uploadS3} from "@/features/channel/components/storages/s3"; +import {copyBlob, deleteBlob, getBlob, pingBlob, uploadBlob} from "@/features/channel/components/storages/az-blob"; import { copyGoogleDrive, deleteGoogleDrive, getGoogleDrive, pingGoogleDrive, uploadGoogleDrive -} from "@/features/channel/storages/google-drive"; +} from "@/features/channel/components/storages/google-drive"; type ProviderHandler = { upload: (config: any, input: StorageInput & { action: 'upload' }) => Promise; diff --git a/src/features/channel/storages/local.schema.ts b/src/features/channel/components/storages/local.schema.ts similarity index 100% rename from src/features/channel/storages/local.schema.ts rename to src/features/channel/components/storages/local.schema.ts diff --git a/src/features/channel/storages/local.ts b/src/features/channel/components/storages/local.ts similarity index 97% rename from src/features/channel/storages/local.ts rename to src/features/channel/components/storages/local.ts index 8dbe8502..afe494c7 100644 --- a/src/features/channel/storages/local.ts +++ b/src/features/channel/components/storages/local.ts @@ -8,9 +8,9 @@ import { StorageMetaData, StorageResult, StorageUploadInput, -} from '@/features/storages/storages.types'; +} from '@/features/storages/types'; import fs from "node:fs"; -import { generateFileUrl } from "@/features/storages/storages.helpers"; +import { generateFileUrl } from "@/features/storages/utils/storages.helpers"; import { Readable } from "node:stream"; import { env } from "@/env.mjs"; diff --git a/src/features/channel/storages/s3.form.tsx b/src/features/channel/components/storages/s3.form.tsx similarity index 100% rename from src/features/channel/storages/s3.form.tsx rename to src/features/channel/components/storages/s3.form.tsx diff --git a/src/features/channel/storages/s3.schema.ts b/src/features/channel/components/storages/s3.schema.ts similarity index 100% rename from src/features/channel/storages/s3.schema.ts rename to src/features/channel/components/storages/s3.schema.ts diff --git a/src/features/channel/storages/s3.ts b/src/features/channel/components/storages/s3.ts similarity index 99% rename from src/features/channel/storages/s3.ts rename to src/features/channel/components/storages/s3.ts index 908b29c1..fc4672c7 100644 --- a/src/features/channel/storages/s3.ts +++ b/src/features/channel/components/storages/s3.ts @@ -6,7 +6,7 @@ import { StorageMetaData, StorageResult, StorageUploadInput -} from '@/features/storages/storages.types'; +} from '@/features/storages/types'; import {Readable} from "node:stream"; type S3Config = { diff --git a/src/features/channel/notifications/channel.action.ts b/src/features/channel/notifications/channel.action.ts deleted file mode 100644 index 94f7837d..00000000 --- a/src/features/channel/notifications/channel.action.ts +++ /dev/null @@ -1,172 +0,0 @@ -"use server"; - -import {z} from "zod"; -import {ServerActionResult} from "@/types/action-type"; -import * as drizzleDb from "@/db"; -import {userAction} from "@/lib/safe-actions/actions"; -import {NotificationChannel} from "@/db/schema/09_notification-channel"; -import {db} from "@/db"; -import {and, eq} from "drizzle-orm"; -import {withUpdatedAt} from "@/db/utils"; -import { - NotificationChannelFormSchema -} from "@/features/channel/channel-form.schema"; - - -export const addNotificationChannelAction = userAction.schema( - z.object({ - organizationId: z.string().optional(), - data: NotificationChannelFormSchema - }) -).action(async ({parsedInput}): Promise> => { - const {organizationId, data} = parsedInput; - try { - const [channel] = await db - .insert(drizzleDb.schemas.notificationChannel) - .values({ - provider: data.provider, - name: data.name, - config: data.config, - enabled: data.enabled ?? true, - organizationId: organizationId ?? null, - }) - .returning(); - - if (organizationId) { - await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({ - organizationId, - notificationChannelId: channel.id, - }); - } - - return { - success: true, - value: { - ...channel, - config: channel.config as JSON - }, - actionSuccess: { - message: "Notification channel has been successfully created.", - messageParams: {notificationChannelId: channel.id}, - }, - }; - } catch (_error) { - const error = _error; - return { - success: false, - actionError: { - message: "Failed to create notification channel.", - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {notificationChannelId: ""}, - }, - }; - } -}); - -export const removeNotificationChannelAction = userAction.schema( - z.object({ - organizationId: z.string().optional(), - notificationChannelId: z.string(), - }) -).action(async ({parsedInput}): Promise> => { - const {organizationId, notificationChannelId} = parsedInput; - - try { - if (organizationId) { - await db - .delete(drizzleDb.schemas.organizationNotificationChannel) - .where( - and( - eq(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationId), - eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId, notificationChannelId) - ) - ); - } - - const [deletedChannel] = await db - .delete(drizzleDb.schemas.notificationChannel) - .where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId)) - .returning(); - - if (!deletedChannel) { - return { - success: false, - actionError: { - message: "Notification channel not found.", - status: 404, - messageParams: {notificationChannelId: notificationChannelId}, - }, - }; - } - - return { - success: true, - value: { - ...deletedChannel, - config: deletedChannel.config as JSON - }, - actionSuccess: { - message: "Notification channel has been successfully removed.", - messageParams: {notificationChannelId: notificationChannelId}, - }, - }; - } catch (_error) { - const error = _error; - return { - success: false, - actionError: { - message: "Failed to remove notification channel.", - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {notificationChannelId: notificationChannelId}, - }, - }; - } -}); - - -export const updateNotificationChannelAction = userAction.schema( - z.object({ - id: z.string(), - data: NotificationChannelFormSchema - }) -).action(async ({parsedInput}): Promise> => { - const {id, data} = parsedInput; - - try { - const [channel] = await db - .update(drizzleDb.schemas.notificationChannel) - .set(withUpdatedAt({ - provider: data.provider, - name: data.name, - config: data.config, - enabled: data.enabled ?? true, - })) - .where(eq(drizzleDb.schemas.notificationChannel.id, id)) - .returning(); - - return { - success: true, - value: { - ...channel, - config: channel.config as JSON - }, - actionSuccess: { - message: `Notification channel "${channel.name}" has been successfully updated.`, - messageParams: {id: channel.id}, - }, - }; - } catch (_error) { - const error = _error; - return { - success: false, - actionError: { - message: "Failed to update notification channel.", - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {id: ""}, - }, - }; - } -}); diff --git a/src/features/channel/channel-form.schema.ts b/src/features/channel/schemas/channel-form.schema.ts similarity index 64% rename from src/features/channel/channel-form.schema.ts rename to src/features/channel/schemas/channel-form.schema.ts index 1fe5d6fe..648687d4 100644 --- a/src/features/channel/channel-form.schema.ts +++ b/src/features/channel/schemas/channel-form.schema.ts @@ -1,18 +1,18 @@ import {z} from "zod"; -import {SlackChannelConfigSchema} from "@/features/channel/notifications/slack.schema"; -import {SmtpChannelConfigSchema} from "@/features/channel/notifications/smtp.schema"; -import {DiscordChannelConfigSchema} from "@/features/channel/notifications/discord.schema"; -import {TelegramChannelConfigSchema} from "@/features/channel/notifications/telegram.schema"; -import {GotifyChannelConfigSchema} from "@/features/channel/notifications/gotify.schema"; -import {NtfyChannelConfigSchema} from "@/features/channel/notifications/ntfy.schema"; -import {WebhookChannelConfigSchema} from "@/features/channel/notifications/webhook.schema"; -import {NextcloudChannelConfigSchema} from "@/features/channel/notifications/nextcloud.schema"; -import {PushoverChannelConfigSchema} from "@/features/channel/notifications/pushover.schema"; -import {S3ChannelConfigSchema} from "@/features/channel/storages/s3.schema"; -import {GoogleDriveChannelConfigSchema} from "@/features/channel/storages/google-drive/google-drive.schema"; -import {LocalChannelConfigSchema} from "@/features/channel/storages/local.schema"; -import {TeamsChannelConfigSchema} from "@/features/channel/notifications/teams.schema"; -import {BlobChannelConfigSchema} from "@/features/channel/storages/az-blob.schema"; +import {SlackChannelConfigSchema} from "@/features/channel/components/notifications/slack.schema"; +import {SmtpChannelConfigSchema} from "@/features/channel/components/notifications/smtp.schema"; +import {DiscordChannelConfigSchema} from "@/features/channel/components/notifications/discord.schema"; +import {TelegramChannelConfigSchema} from "@/features/channel/components/notifications/telegram.schema"; +import {GotifyChannelConfigSchema} from "@/features/channel/components/notifications/gotify.schema"; +import {NtfyChannelConfigSchema} from "@/features/channel/components/notifications/ntfy.schema"; +import {WebhookChannelConfigSchema} from "@/features/channel/components/notifications/webhook.schema"; +import {NextcloudChannelConfigSchema} from "@/features/channel/components/notifications/nextcloud.schema"; +import {PushoverChannelConfigSchema} from "@/features/channel/components/notifications/pushover.schema"; +import {S3ChannelConfigSchema} from "@/features/channel/components/storages/s3.schema"; +import {GoogleDriveChannelConfigSchema} from "@/features/channel/components/storages/google-drive/google-drive.schema"; +import {LocalChannelConfigSchema} from "@/features/channel/components/storages/local.schema"; +import {TeamsChannelConfigSchema} from "@/features/channel/components/notifications/teams.schema"; +import {BlobChannelConfigSchema} from "@/features/channel/components/storages/az-blob.schema"; const BaseChannelFormSchema = z.object({ diff --git a/src/features/channel/storages/channel.action.ts b/src/features/channel/storages/channel.action.ts deleted file mode 100644 index 6df4ab2b..00000000 --- a/src/features/channel/storages/channel.action.ts +++ /dev/null @@ -1,173 +0,0 @@ -"use server"; - -import {z} from "zod"; -import {ServerActionResult} from "@/types/action-type"; -import * as drizzleDb from "@/db"; -import {userAction} from "@/lib/safe-actions/actions"; -import {db} from "@/db"; -import {and, eq} from "drizzle-orm"; -import {withUpdatedAt} from "@/db/utils"; -import { - StorageChannelFormSchema -} from "@/features/channel/channel-form.schema"; -import {StorageChannel} from "@/db/schema/12_storage-channel"; - - -export const addStorageChannelAction = userAction.schema( - z.object({ - organizationId: z.string().optional(), - data: StorageChannelFormSchema - }) -).action(async ({parsedInput}): Promise> => { - const {organizationId, data} = parsedInput; - - try { - const [channel] = await db - .insert(drizzleDb.schemas.storageChannel) - .values({ - provider: data.provider, - name: data.name, - config: data.config, - enabled: data.enabled ?? true, - organizationId: organizationId ?? null - }) - .returning(); - - if (organizationId) { - await db.insert(drizzleDb.schemas.organizationStorageChannel).values({ - organizationId, - storageChannelId: channel.id, - }); - } - - return { - success: true, - value: { - ...channel, - config: channel.config as JSON - }, - actionSuccess: { - message: "Storage channel has been successfully created.", - messageParams: {id: channel.id}, - }, - }; - } catch (_error) { - const error = _error; - return { - success: false, - actionError: { - message: "Failed to create storage channel.", - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {id: ""}, - }, - }; - } -}); - -export const removeStorageChannelAction = userAction.schema( - z.object({ - organizationId: z.string().optional(), - id: z.string(), - }) -).action(async ({parsedInput}): Promise> => { - const {organizationId, id} = parsedInput; - - try { - if (organizationId) { - await db - .delete(drizzleDb.schemas.organizationStorageChannel) - .where( - and( - eq(drizzleDb.schemas.organizationStorageChannel.organizationId, organizationId), - eq(drizzleDb.schemas.organizationStorageChannel.storageChannelId, id) - ) - ); - } - - const [deletedChannel] = await db - .delete(drizzleDb.schemas.storageChannel) - .where(eq(drizzleDb.schemas.storageChannel.id, id)) - .returning(); - - if (!deletedChannel) { - return { - success: false, - actionError: { - message: "Storage channel not found.", - status: 404, - messageParams: {id: id}, - }, - }; - } - - return { - success: true, - value: { - ...deletedChannel, - config: deletedChannel.config as JSON - }, - actionSuccess: { - message: "Storage channel has been successfully removed.", - messageParams: {id: id}, - }, - }; - } catch (_error) { - const error = _error; - return { - success: false, - actionError: { - message: "Failed to remove storage channel.", - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {id: id}, - }, - }; - } -}); - - -export const updateStorageChannelAction = userAction.schema( - z.object({ - id: z.string(), - data: StorageChannelFormSchema - }) -).action(async ({parsedInput}): Promise> => { - const {id, data} = parsedInput; - - try { - const [channel] = await db - .update(drizzleDb.schemas.storageChannel) - .set(withUpdatedAt({ - provider: data.provider, - name: data.name, - config: data.config, - enabled: data.enabled ?? true, - })) - .where(eq(drizzleDb.schemas.storageChannel.id, id)) - .returning(); - - return { - success: true, - value: { - ...channel, - config: channel.config as JSON - }, - actionSuccess: { - message: `Storage channel "${channel.name}" has been successfully updated.`, - messageParams: {id: channel.id}, - }, - }; - } catch (_error) { - const error = _error; - return { - success: false, - actionError: { - message: "Failed to update storage channel.", - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {id: ""}, - }, - }; - } -}); diff --git a/src/features/database/backup-actions.action.ts b/src/features/database/actions/backup-actions.action.ts similarity index 98% rename from src/features/database/backup-actions.action.ts rename to src/features/database/actions/backup-actions.action.ts index bb183608..7847e05b 100644 --- a/src/features/database/backup-actions.action.ts +++ b/src/features/database/actions/backup-actions.action.ts @@ -2,8 +2,8 @@ import {userAction} from "@/lib/safe-actions/actions"; import {ServerActionResult} from "@/types/action-type"; import {z} from "zod"; -import type {StorageInput} from "@/features/storages/storages.types"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; +import type {StorageInput} from "@/features/storages/types"; +import {dispatchStorage} from "@/features/storages/utils/storages.dispatch"; import {db} from "@/db"; import {and, eq, isNull, ne, sql} from "drizzle-orm"; import * as drizzleDb from "@/db"; diff --git a/src/features/database/backup-button.action.ts b/src/features/database/actions/backup-button.action.ts similarity index 100% rename from src/features/database/backup-button.action.ts rename to src/features/database/actions/backup-button.action.ts diff --git a/src/features/database/backup-get-data.action.ts b/src/features/database/actions/backup-get-data.action.ts similarity index 100% rename from src/features/database/backup-get-data.action.ts rename to src/features/database/actions/backup-get-data.action.ts diff --git a/src/features/database/channels-policy.action.ts b/src/features/database/actions/channels-policy.action.ts similarity index 99% rename from src/features/database/channels-policy.action.ts rename to src/features/database/actions/channels-policy.action.ts index 1ad9ffa5..21a35241 100644 --- a/src/features/database/channels-policy.action.ts +++ b/src/features/database/actions/channels-policy.action.ts @@ -7,7 +7,7 @@ import * as drizzleDb from "@/db"; import {and, eq, inArray} from "drizzle-orm"; import {withUpdatedAt} from "@/db/utils"; import {AlertPolicy} from "@/db/schema/10_alert-policy"; -import {PolicySchema} from "@/features/database/channels-policy.schema"; +import {PolicySchema} from "@/features/database/schemas/channels-policy.schema"; import {StoragePolicy} from "@/db/schema/13_storage-policy"; diff --git a/src/features/database/cron.action.ts b/src/features/database/actions/cron.action.ts similarity index 100% rename from src/features/database/cron.action.ts rename to src/features/database/actions/cron.action.ts diff --git a/src/features/database/database-form.action.ts b/src/features/database/actions/database-form.action.ts similarity index 88% rename from src/features/database/database-form.action.ts rename to src/features/database/actions/database-form.action.ts index 3165ea05..29d26c31 100644 --- a/src/features/database/database-form.action.ts +++ b/src/features/database/actions/database-form.action.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { db } from "@/db"; import { eq } from "drizzle-orm"; -import { DatabaseSchema } from "@/features/database/database-form.schema"; +import { DatabaseSchema } from "@/features/database/schemas/database-form.schema"; import * as drizzleDb from "@/db"; import {userAction} from "@/lib/safe-actions/actions"; import {Database} from "@/db/schema/07_database"; diff --git a/src/features/database/import.action.ts b/src/features/database/actions/import.action.ts similarity index 97% rename from src/features/database/import.action.ts rename to src/features/database/actions/import.action.ts index 155ef709..ad14738b 100644 --- a/src/features/database/import.action.ts +++ b/src/features/database/actions/import.action.ts @@ -7,7 +7,7 @@ import {Backup} from "@/db/schema/07_database"; import {v4 as uuidv4} from "uuid"; import {eq} from "drizzle-orm"; import {z} from "zod"; -import {storeBackupFiles} from "@/features/storages/storages.helpers"; +import {storeBackupFiles} from "@/features/storages/utils/storages.helpers"; import {getFileExtension} from "@/utils/common"; diff --git a/src/features/database/restore.action.ts b/src/features/database/actions/restore.action.ts similarity index 100% rename from src/features/database/restore.action.ts rename to src/features/database/actions/restore.action.ts diff --git a/src/features/database/retention-policy.action.ts b/src/features/database/actions/retention-policy.action.ts similarity index 97% rename from src/features/database/retention-policy.action.ts rename to src/features/database/actions/retention-policy.action.ts index d4f42652..c5e77e18 100644 --- a/src/features/database/retention-policy.action.ts +++ b/src/features/database/actions/retention-policy.action.ts @@ -6,7 +6,7 @@ import {eq} from "drizzle-orm" import * as drizzleDb from "@/db"; import { RetentionSettingsSchema -} from "@/features/database/retention-policy.schema"; +} from "@/features/database/schemas/retention-policy.schema"; import {userAction} from "@/lib/safe-actions/actions"; diff --git a/src/features/database/channels-policy-modal.tsx b/src/features/database/channels-policy-modal.tsx deleted file mode 100644 index ee9ebb6b..00000000 --- a/src/features/database/channels-policy-modal.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client" -import {ReactNode, useState} from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger -} from "@/components/ui/dialog"; -import {Button} from "@/components/ui/button"; -import {DatabaseWith} from "@/db/schema/07_database"; -import {NotificationChannel} from "@/db/schema/09_notification-channel"; -import {Separator} from "@/components/ui/separator"; -import {Badge} from "@/components/ui/badge"; -import {StorageChannel} from "@/db/schema/12_storage-channel"; -import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/channels-helpers"; -import {ChannelPoliciesForm} from "@/features/database/channels-policy-form"; - - -type ChannelPoliciesModalProps = { - database: DatabaseWith; - channels: NotificationChannel[] | StorageChannel[]; - organizationId: string; - kind: ChannelKind; - icon: ReactNode; - -} - -export const ChannelPoliciesModal = ({icon, kind, database, channels, organizationId}: ChannelPoliciesModalProps) => { - const [open, setOpen] = useState(false); - const channelText = getChannelTextBasedOnKind(kind) - - - const channelsFiltered = channels - .filter((channel) => channel.enabled) - - const channelsIds = channelsFiltered - .map(channel => channel.id); - const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId)); - const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId)); - - - const activePolicies = kind === "notification" ? activeAlertPolicies : activeStoragePolicies; - - return ( - - - - - - - {channelText} policies - - Add and manage your database {channelText.toLowerCase()} policies - - - setOpen(false)} - kind={kind} - /> - - - - ) -} \ No newline at end of file diff --git a/src/features/database/backup-actions-cell.tsx b/src/features/database/components/backup-actions-cell.tsx similarity index 97% rename from src/features/database/backup-actions-cell.tsx rename to src/features/database/components/backup-actions-cell.tsx index 4d04f1d0..b7016b12 100644 --- a/src/features/database/backup-actions-cell.tsx +++ b/src/features/database/components/backup-actions-cell.tsx @@ -1,7 +1,7 @@ "use client"; import {BackupWith} from "@/db/schema/07_database"; -import {useBackupModal} from "@/features/database/backup-modal-context"; +import {useBackupModal} from "@/features/database/components/backup-modal-context"; import {Button} from "@/components/ui/button"; import { DropdownMenu, diff --git a/src/features/database/backup-actions-form.tsx b/src/features/database/components/backup-actions-form.tsx similarity index 96% rename from src/features/database/backup-actions-form.tsx rename to src/features/database/components/backup-actions-form.tsx index a9df46da..8dbb64e8 100644 --- a/src/features/database/backup-actions-form.tsx +++ b/src/features/database/components/backup-actions-form.tsx @@ -12,25 +12,25 @@ import { } from "@/components/ui/form"; import { TooltipProvider } from "@/components/ui/tooltip"; import { ButtonWithLoading } from "@/components/common/button-with-loading"; -import { getChannelIcon } from "@/features/channel/channels-helpers"; +import { getChannelIcon } from "@/features/channel/components/channels-helpers"; import { getStatusColor, getStatusIcon, -} from "@/features/notifications/notification-log-columns"; +} from "@/features/notifications/components/notification-log-columns"; import { createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction, downloadBackupAction, -} from "@/features/database/backup-actions.action"; +} from "@/features/database/actions/backup-actions.action"; import { BackupActionsSchema, BackupActionsType, -} from "@/features/database/backup-actions.schema"; +} from "@/features/database/schemas/backup-actions.schema"; import { DatabaseActionKind, useBackupModal, -} from "@/features/database/backup-modal-context"; +} from "@/features/database/components/backup-modal-context"; import { Backup, BackupWith, Restoration } from "@/db/schema/07_database"; import { BackupStorageWith } from "@/db/schema/14_storage-backup"; import { ServerActionResult } from "@/types/action-type"; diff --git a/src/features/database/backup-actions-modal.tsx b/src/features/database/components/backup-actions-modal.tsx similarity index 88% rename from src/features/database/backup-actions-modal.tsx rename to src/features/database/components/backup-actions-modal.tsx index 0e7764d4..983e6e84 100644 --- a/src/features/database/backup-actions-modal.tsx +++ b/src/features/database/components/backup-actions-modal.tsx @@ -7,11 +7,11 @@ import { DialogTitle, } from "@/components/ui/dialog"; import {Separator} from "@/components/ui/separator"; -import {BackupActionsForm} from "@/features/database/backup-actions-form"; +import {BackupActionsForm} from "@/features/database/components/backup-actions-form"; import { getBackupActionTextBasedOnActionKind, useBackupModal -} from "@/features/database/backup-modal-context"; +} from "@/features/database/components/backup-modal-context"; type DatabaseActionsModalProps = {} diff --git a/src/features/database/backup-button.tsx b/src/features/database/components/backup-button.tsx similarity index 95% rename from src/features/database/backup-button.tsx rename to src/features/database/components/backup-button.tsx index 5278b633..cc8a44bd 100644 --- a/src/features/database/backup-button.tsx +++ b/src/features/database/components/backup-button.tsx @@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { backupButtonAction } from "@/features/database/backup-button.action"; +import { backupButtonAction } from "@/features/database/actions/backup-button.action"; import { Check, DatabaseZap, X } from "lucide-react"; import { useIsMobile } from "@/hooks/use-mobile"; import { ButtonWithConfirm } from "@/components/common/button-with-confirm"; diff --git a/src/features/database/backup-columns.tsx b/src/features/database/components/backup-columns.tsx similarity index 94% rename from src/features/database/backup-columns.tsx rename to src/features/database/components/backup-columns.tsx index b1d4242a..e6ce4f37 100644 --- a/src/features/database/backup-columns.tsx +++ b/src/features/database/components/backup-columns.tsx @@ -9,10 +9,10 @@ import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/compon import {MemberWithUser} from "@/db/schema/03_organization"; import {formatLocalizedDate} from "@/utils/date-formatting"; import {formatBytes, formatDuration} from "@/utils/text"; -import {DatabaseActionsCell} from "@/features/database/backup-actions-cell"; +import {DatabaseActionsCell} from "@/features/database/components/backup-actions-cell"; import { Badge as BadgeC } from "@/components/ui/badge"; -import {backupOnly} from "@/features/database/database-tabs"; -import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger"; +import {backupOnly} from "@/features/database/components/database-tabs"; +import {LogsModalTrigger} from "@/features/logs/components/logs-modal-trigger"; export function backupColumns( isAlreadyRestore: boolean, diff --git a/src/features/database/backup-modal-context.tsx b/src/features/database/components/backup-modal-context.tsx similarity index 100% rename from src/features/database/backup-modal-context.tsx rename to src/features/database/components/backup-modal-context.tsx diff --git a/src/features/database/channels-policy-form.tsx b/src/features/database/components/channels-policy-form.tsx similarity index 55% rename from src/features/database/channels-policy-form.tsx rename to src/features/database/components/channels-policy-form.tsx index 4ea8e482..400b510c 100644 --- a/src/features/database/channels-policy-form.tsx +++ b/src/features/database/components/channels-policy-form.tsx @@ -1,149 +1,73 @@ -import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; -import {InfoIcon, Plus, Trash2} from "lucide-react"; -import {useFieldArray} from "react-hook-form"; -import {DatabaseWith} from "@/db/schema/07_database"; -import {NotificationChannel} from "@/db/schema/09_notification-channel"; -import {Label} from "@/components/ui/label"; -import {Button} from "@/components/ui/button"; -import {ButtonWithLoading} from "@/components/common/button-with-loading"; -import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; -import {MultiSelect} from "@/components/common/multi-select"; -import {useMutation, useQueryClient} from "@tanstack/react-query"; -import {toast} from "sonner"; -import {Switch} from "@/components/ui/switch"; -import {Card} from "@/components/ui/card"; -import Link from "next/link"; -import {useIsMobile} from "@/hooks/use-mobile"; -import {useRouter} from "next/navigation"; -import { - ChannelKind, - getChannelIcon, - getChannelTextBasedOnKind -} from "@/features/channel/channels-helpers"; -import {StorageChannel} from "@/db/schema/12_storage-channel"; +"use client"; + +import { ReactNode } from "react"; +import { InfoIcon, Plus, Trash2 } from "lucide-react"; +import { useFieldArray } from "react-hook-form"; +import { toast } from "sonner"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { ButtonWithLoading } from "@/components/common/button-with-loading"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { MultiSelect } from "@/components/common/multi-select"; +import { Switch } from "@/components/ui/switch"; +import { Card } from "@/components/ui/card"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { ChannelKind, getChannelIcon, getChannelTextBasedOnKind } from "@/features/channel/components/channels-helpers"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form"; import { EVENT_KIND_BACKUP_ONLY_OPTIONS, EVENT_KIND_OPTIONS, PoliciesSchema, PoliciesType, - PolicyType -} from "@/features/database/channels-policy.schema"; -import { - createAlertPoliciesAction, createStoragePoliciesAction, deleteAlertPoliciesAction, deleteStoragePoliciesAction, - updateAlertPoliciesAction, updateStoragePoliciesAction -} from "@/features/database/channels-policy.action"; -import {backupOnly} from "@/features/database/database-tabs"; + PolicyType, +} from "@/features/database/schemas/channels-policy.schema"; + +export type ChannelEntry = { id: string; name: string; provider: string }; type ChannelPoliciesFormProps = { - onSuccess?: () => void; - channels: NotificationChannel[] | StorageChannel[]; - database: DatabaseWith; - kind: ChannelKind + channels: ChannelEntry[]; + defaultPolicies: PolicyType[]; + kind: ChannelKind; + isBackupOnly?: boolean; + isPending?: boolean; + onSave: (policies: PolicyType[]) => Promise; + onCancel?: () => void; + noChannelsMessage?: ReactNode; }; - export const ChannelPoliciesForm = ({ - database, - channels, - onSuccess, - kind - }: ChannelPoliciesFormProps) => { - const queryClient = useQueryClient(); - const router = useRouter(); + channels, + defaultPolicies, + kind, + isBackupOnly = false, + isPending = false, + onSave, + onCancel, + noChannelsMessage, +}: ChannelPoliciesFormProps) => { const isMobile = useIsMobile(); const channelText = getChannelTextBasedOnKind(kind); - const isBackupOnly = backupOnly.some((type) => database.dbms === type); - - - const organizationChannels = channels.map(c => c.id); - - const filterByChannel = ( - items: T[] | undefined | null, - channelKey: K - ): T[] => items?.filter(item => organizationChannels.includes(item[channelKey] as string)) ?? []; - - const formattedAlertPolicies = filterByChannel(database.alertPolicies, "notificationChannelId") - .map(({notificationChannelId, eventKinds, enabled}) => ({ - channelId: notificationChannelId, - eventKinds, - enabled - })); - - const formattedStoragePolicies = filterByChannel(database.storagePolicies, "storageChannelId") - .map(({storageChannelId, enabled}) => ({ - channelId: storageChannelId, - enabled - })); - - const defaultPolicies: PolicyType[] = - kind === "notification" - ? formattedAlertPolicies - : formattedStoragePolicies.map(({ channelId, enabled }) => ({ channelId, enabled })); - const form = useZodForm({ schema: PoliciesSchema, defaultValues: { policies: defaultPolicies }, - context: { kind } + context: { kind }, }); - const {fields, append, remove} = useFieldArray({ control: form.control, name: "policies" }); + const { fields, append, remove } = useFieldArray({ control: form.control, name: "policies" }); - const addPolicy = () => append({channelId: "", eventKinds: [], enabled: true}); - const removePolicyHandler = (index: number) => remove(index); - const onCancel = () => { form.reset(); onSuccess?.(); }; + const addPolicy = () => append({ channelId: "", eventKinds: [], enabled: true }); - const mutation = useMutation({ - mutationFn: async ({policies}: PoliciesType) => { - const payload = policies.map(p => kind === "notification" ? p : { ...p, eventKinds: undefined }); - - const policiesToAdd = payload.filter( - (policy) => !defaultPolicies.some((a) => a.channelId === policy.channelId) - ); - const policiesToRemove = defaultPolicies.filter( - (policy) => !payload.some((v) => v.channelId === policy.channelId) - ); - const policiesToUpdate = payload.filter((policy) => { - const existing = defaultPolicies.find((a) => a.channelId === policy.channelId); - return existing && - (existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled); - }); - const promises = kind === "notification" - ? [ - policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null, - policiesToUpdate.length > 0 ? await updateAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToUpdate}) : null, - policiesToRemove.length > 0 ? await deleteAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToRemove}) : null, - ] - : [ - policiesToAdd.length > 0 ? await createStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToAdd}) : null, - policiesToUpdate.length > 0 ? await updateStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToUpdate}) : null, - policiesToRemove.length > 0 ? await deleteStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToRemove}) : null, - ]; - - const results = await Promise.allSettled(promises); - const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); - if (rejected) throw new Error(rejected.reason?.message || "Network or server error"); - - const failedActions = results - .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") - .map(r => r.value) - .filter((v): v is { data: { success: false; actionError: any } } => v !== null && v.data?.success === false); - - if (failedActions.length > 0) throw new Error(failedActions[0].data.actionError?.message || "One or more operations failed"); - return {success: true}; - }, - onSuccess: () => { - toast.success("Policies saved successfully"); - queryClient.invalidateQueries({queryKey: ["database-data", database.id]}); - router.refresh(); - }, - onError: (error: any) => { toast.error(error.message || "Failed to save policies"); }, - }); + const handleCancel = () => { + form.reset(); + onCancel?.(); + }; return ( -
{ - + { if (kind === "notification") { for (const policy of values.policies) { if (!policy.eventKinds || policy.eventKinds.length === 0) { @@ -152,10 +76,9 @@ export const ChannelPoliciesForm = ({ } } } - - await mutation.mutateAsync(values) - } - }> + await onSave(values.policies); + }} + >
@@ -175,38 +98,44 @@ export const ChannelPoliciesForm = ({ type="button" size="sm" className="h-8" - onClick={addPolicy}> - Add Policy + onClick={addPolicy} + > + Add Policy
{channels.length === 0 ? (
- +

No channels

-

- Please - configure {channelText.toLowerCase()} channels - in your organization settings first. -

+ {noChannelsMessage ?? ( +

+ No {channelText.toLowerCase()} channels configured. +

+ )}
) : fields.length === 0 ? (
- +

No policies

- {kind === "notification" ? `Click "Add Policy" to start receiving notifications.` : `Click "Add Policy" to use this storage.`} + {kind === "notification" + ? `Click "Add Policy" to start receiving notifications.` + : `Click "Add Policy" to use this storage.`}

) : (
{fields.map((field, index) => ( - +
-
+
- + ( + render={({ field }) => (
{!isMobile && ( -
@@ -285,10 +236,14 @@ export const ChannelPoliciesForm = ({
-
@@ -297,23 +252,29 @@ export const ChannelPoliciesForm = ({ ( + render={({ field }) => ( - Trigger Events + + Trigger Events +
- +
)} /> @@ -327,9 +288,13 @@ export const ChannelPoliciesForm = ({
- Cancel - Save Changes + {onCancel && ( + + Cancel + + )} + Save Changes
); -}; \ No newline at end of file +}; diff --git a/src/features/database/components/channels-policy-modal.tsx b/src/features/database/components/channels-policy-modal.tsx new file mode 100644 index 00000000..c9f644e2 --- /dev/null +++ b/src/features/database/components/channels-policy-modal.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { ReactNode, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import Link from "next/link"; +import { DatabaseWith } from "@/db/schema/07_database"; +import { NotificationChannel } from "@/db/schema/09_notification-channel"; +import { StorageChannel } from "@/db/schema/12_storage-channel"; +import { ChannelKind, getChannelTextBasedOnKind } from "@/features/channel/components/channels-helpers"; +import { ChannelPoliciesForm } from "@/features/database/components/channels-policy-form"; +import { PolicyType } from "@/features/database/schemas/channels-policy.schema"; +import { + createAlertPoliciesAction, + createStoragePoliciesAction, + deleteAlertPoliciesAction, + deleteStoragePoliciesAction, + updateAlertPoliciesAction, + updateStoragePoliciesAction, +} from "@/features/database/actions/channels-policy.action"; +import { backupOnly } from "@/features/database/components/database-tabs"; + +type ChannelPoliciesModalProps = { + database: DatabaseWith; + channels: NotificationChannel[] | StorageChannel[]; + organizationId: string; + kind: ChannelKind; + icon: ReactNode; +}; + +export const ChannelPoliciesModal = ({ icon, kind, database, channels, organizationId }: ChannelPoliciesModalProps) => { + const [open, setOpen] = useState(false); + const queryClient = useQueryClient(); + const router = useRouter(); + const channelText = getChannelTextBasedOnKind(kind); + + const channelsFiltered = channels.filter((c) => c.enabled); + const channelIds = channelsFiltered.map((c) => c.id); + + const defaultPolicies: PolicyType[] = + kind === "notification" + ? (database.alertPolicies ?? []) + .filter((p) => channelIds.includes(p.notificationChannelId)) + .map(({ notificationChannelId, eventKinds, enabled }) => ({ + channelId: notificationChannelId, + eventKinds, + enabled, + })) + : (database.storagePolicies ?? []) + .filter((p) => channelIds.includes(p.storageChannelId)) + .map(({ storageChannelId, enabled }) => ({ channelId: storageChannelId, enabled })); + + const activePolicies = kind === "notification" + ? database.alertPolicies?.filter((p) => channelIds.includes(p.notificationChannelId)) + : database.storagePolicies?.filter((p) => channelIds.includes(p.storageChannelId)); + + const mutation = useMutation({ + mutationFn: async (policies: PolicyType[]) => { + const payload = policies.map((p) => + kind === "notification" ? p : { ...p, eventKinds: undefined }, + ); + + const toAdd = payload.filter((p) => !defaultPolicies.some((d) => d.channelId === p.channelId)); + const toRemove = defaultPolicies.filter((d) => !payload.some((p) => p.channelId === d.channelId)); + const toUpdate = payload.filter((p) => { + const existing = defaultPolicies.find((d) => d.channelId === p.channelId); + return existing && (existing.eventKinds !== p.eventKinds || existing.enabled !== p.enabled); + }); + + const results = await Promise.allSettled( + kind === "notification" + ? [ + toAdd.length > 0 + ? createAlertPoliciesAction({ databaseId: database.id, alertPolicies: toAdd }) + : null, + toUpdate.length > 0 + ? updateAlertPoliciesAction({ databaseId: database.id, alertPolicies: toUpdate }) + : null, + toRemove.length > 0 + ? deleteAlertPoliciesAction({ databaseId: database.id, alertPolicies: toRemove }) + : null, + ] + : [ + toAdd.length > 0 + ? createStoragePoliciesAction({ databaseId: database.id, storagePolicies: toAdd }) + : null, + toUpdate.length > 0 + ? updateStoragePoliciesAction({ databaseId: database.id, storagePolicies: toUpdate }) + : null, + toRemove.length > 0 + ? deleteStoragePoliciesAction({ databaseId: database.id, storagePolicies: toRemove }) + : null, + ], + ); + + const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); + if (rejected) throw new Error(rejected.reason?.message || "Network or server error"); + + const failed = results + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") + .map((r) => r.value) + .filter((v): v is { data: { success: false; actionError: any } } => v !== null && v.data?.success === false); + + if (failed.length > 0) throw new Error(failed[0].data.actionError?.message || "One or more operations failed"); + }, + onSuccess: () => { + toast.success("Policies saved successfully"); + queryClient.invalidateQueries({ queryKey: ["database-data", database.id] }); + router.refresh(); + setOpen(false); + }, + onError: (error: any) => { + toast.error(error.message || "Failed to save policies"); + }, + }); + + return ( + + + + + + + {channelText} policies + + Add and manage your database {channelText.toLowerCase()} policies + + + ({ id: c.id, name: c.name, provider: c.provider }))} + defaultPolicies={defaultPolicies} + kind={kind} + isBackupOnly={backupOnly.some((t) => database.dbms === t)} + isPending={mutation.isPending} + onSave={mutation.mutateAsync} + onCancel={() => setOpen(false)} + noChannelsMessage={ +

+ Please{" "} + + configure {channelText.toLowerCase()} channels + {" "} + in your organization settings first. +

+ } + /> +
+
+
+ ); +}; diff --git a/src/features/database/cron-advanced-select.tsx b/src/features/database/components/cron-advanced-select.tsx similarity index 100% rename from src/features/database/cron-advanced-select.tsx rename to src/features/database/components/cron-advanced-select.tsx diff --git a/src/features/database/cron-button.tsx b/src/features/database/components/cron-button.tsx similarity index 96% rename from src/features/database/cron-button.tsx rename to src/features/database/components/cron-button.tsx index 8d2b9a85..80d7e045 100644 --- a/src/features/database/cron-button.tsx +++ b/src/features/database/components/cron-button.tsx @@ -2,7 +2,7 @@ import { Clock9 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { CronInput } from "@/features/database/cron-input"; +import { CronInput } from "@/features/database/components/cron-input"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; @@ -10,7 +10,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { updateDatabaseBackupPolicyAction } from "@/features/database/cron.action"; +import { updateDatabaseBackupPolicyAction } from "@/features/database/actions/cron.action"; import {Database} from "@/db/schema/07_database"; export type CronButtonProps = { diff --git a/src/features/database/cron-input.tsx b/src/features/database/components/cron-input.tsx similarity index 97% rename from src/features/database/cron-input.tsx rename to src/features/database/components/cron-input.tsx index 50af570f..52f3bd73 100644 --- a/src/features/database/cron-input.tsx +++ b/src/features/database/components/cron-input.tsx @@ -1,5 +1,5 @@ -import {AdvancedCronSelect} from "@/features/database/cron-advanced-select"; -import {updateDatabaseBackupPolicyAction} from "@/features/database/cron.action"; +import {AdvancedCronSelect} from "@/features/database/components/cron-advanced-select"; +import {updateDatabaseBackupPolicyAction} from "@/features/database/actions/cron.action"; import {useMutation, useQueryClient} from "@tanstack/react-query"; import {useRouter} from "next/navigation"; import {useState} from "react"; diff --git a/src/features/database/database-backup-list.tsx b/src/features/database/components/database-backup-list.tsx similarity index 97% rename from src/features/database/database-backup-list.tsx rename to src/features/database/components/database-backup-list.tsx index d66f266b..fb6fe8ec 100644 --- a/src/features/database/database-backup-list.tsx +++ b/src/features/database/components/database-backup-list.tsx @@ -1,5 +1,5 @@ "use client" -import {backupColumns} from "@/features/database/backup-columns"; +import {backupColumns} from "@/features/database/components/backup-columns"; import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; import {ButtonWithLoading} from "@/components/common/button-with-loading"; import {MoreHorizontal, Trash2} from "lucide-react"; @@ -11,7 +11,7 @@ import {Setting} from "@/db/schema/01_setting"; import {useMutation, useQueryClient} from "@tanstack/react-query"; import {toast} from "sonner"; import {MemberWithUser} from "@/db/schema/03_organization"; -import {deleteBackupAction} from "@/features/database/backup-actions.action"; +import {deleteBackupAction} from "@/features/database/actions/backup-actions.action"; import {ButtonWithConfirm} from "@/components/common/button-with-confirm"; diff --git a/src/features/database/database-content.tsx b/src/features/database/components/database-content.tsx similarity index 86% rename from src/features/database/database-content.tsx rename to src/features/database/components/database-content.tsx index f6b9e027..2038ef71 100644 --- a/src/features/database/database-content.tsx +++ b/src/features/database/components/database-content.tsx @@ -1,29 +1,29 @@ "use client"; -import {DatabaseBackupActionsModal} from "@/features/database/backup-actions-modal"; -import {DatabaseTabs} from "@/features/database/database-tabs"; +import {DatabaseBackupActionsModal} from "@/features/database/components/backup-actions-modal"; +import {DatabaseTabs} from "@/features/database/components/database-tabs"; import {Setting} from "@/db/schema/01_setting"; import {BackupWith, DatabaseWith, RestorationWith} from "@/db/schema/07_database"; import {MemberWithUser} from "@/db/schema/03_organization"; -import {useBackupModal} from "@/features/database/backup-modal-context"; -import {DatabaseKpi} from "@/features/database/database-kpi"; +import {useBackupModal} from "@/features/database/components/backup-modal-context"; +import {DatabaseKpi} from "@/features/database/components/database-kpi"; import {useQuery} from "@tanstack/react-query"; -import {getDatabaseDataAction} from "@/features/database/backup-get-data.action"; +import {getDatabaseDataAction} from "@/features/database/actions/backup-get-data.action"; import { PageContent, PageDescription, PageTitle, -} from "@/features/layout/page"; +} from "@/features/layout/components/page"; import {capitalizeFirstLetter} from "@/utils/text"; -import {RetentionPolicySheet} from "@/features/database/retention-policy-sheet"; -import {CronButton} from "@/features/database/cron-button"; -import {ChannelPoliciesModal} from "@/features/database/channels-policy-modal"; +import {RetentionPolicySheet} from "@/features/database/components/retention-policy-sheet"; +import {CronButton} from "@/features/database/components/cron-button"; +import {ChannelPoliciesModal} from "@/features/database/components/channels-policy-modal"; import {HardDrive, Megaphone} from "lucide-react"; -import {ImportModal} from "@/features/database/import-modal"; -import {BackupButton} from "@/features/database/backup-button"; -import {HealthModal} from "@/features/database/health-modal"; +import {ImportModal} from "@/features/database/components/import-modal"; +import {BackupButton} from "@/features/database/components/backup-button"; +import {HealthModal} from "@/features/database/components/health-modal"; import {HealthcheckLog} from "@/db/schema/15_healthcheck-log"; import {Badge} from "@/components/ui/badge"; -import {LogsModal} from "@/features/logs/logs-modal"; +import {LogsModal} from "@/features/logs/components/logs-modal"; export type DatabaseContentProps = { settings: Setting; diff --git a/src/features/database/database-form.tsx b/src/features/database/components/database-form.tsx similarity index 97% rename from src/features/database/database-form.tsx rename to src/features/database/components/database-form.tsx index 136dc24d..8562c0cb 100644 --- a/src/features/database/database-form.tsx +++ b/src/features/database/components/database-form.tsx @@ -8,8 +8,8 @@ import { Button } from "@/components/ui/button"; import { useRouter } from "next/navigation"; import { useMutation } from "@tanstack/react-query"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { DatabaseSchema, DatabaseType } from "@/features/database/database-form.schema"; -import { updateDatabaseAction } from "@/features/database/database-form.action"; +import { DatabaseSchema, DatabaseType } from "@/features/database/schemas/database-form.schema"; +import { updateDatabaseAction } from "@/features/database/actions/database-form.action"; import { toast } from "sonner"; export type DatabaseFormProps = { diff --git a/src/features/database/database-kpi.tsx b/src/features/database/components/database-kpi.tsx similarity index 100% rename from src/features/database/database-kpi.tsx rename to src/features/database/components/database-kpi.tsx diff --git a/src/features/database/database-restore-list.tsx b/src/features/database/components/database-restore-list.tsx similarity index 96% rename from src/features/database/database-restore-list.tsx rename to src/features/database/components/database-restore-list.tsx index ea1ce5b7..649ed1a0 100644 --- a/src/features/database/database-restore-list.tsx +++ b/src/features/database/components/database-restore-list.tsx @@ -1,12 +1,12 @@ "use client" import {DataTable} from "@/components/common/data-table"; -import {restoreColumns} from "@/features/database/restore-columns"; +import {restoreColumns} from "@/features/database/components/restore-columns"; import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; import {ButtonWithLoading} from "@/components/common/button-with-loading"; import {MoreHorizontal, Trash2} from "lucide-react"; import {Restoration} from "@/db/schema/07_database"; import {useMutation, useQueryClient} from "@tanstack/react-query"; -import {deleteRestoreAction} from "@/features/database/restore.action"; +import {deleteRestoreAction} from "@/features/database/actions/restore.action"; import {toast} from "sonner"; import {MemberWithUser} from "@/db/schema/03_organization"; import {useMemo, useState} from "react"; diff --git a/src/features/database/database-tabs.tsx b/src/features/database/components/database-tabs.tsx similarity index 94% rename from src/features/database/database-tabs.tsx rename to src/features/database/components/database-tabs.tsx index 2c8a622e..a1b91e2e 100644 --- a/src/features/database/database-tabs.tsx +++ b/src/features/database/components/database-tabs.tsx @@ -5,8 +5,8 @@ import {useEffect, useState} from "react"; import {useRouter, useSearchParams} from "next/navigation"; import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database"; import {Setting} from "@/db/schema/01_setting"; -import {DatabaseBackupList} from "@/features/database/database-backup-list"; -import {DatabaseRestoreList} from "@/features/database/database-restore-list"; +import {DatabaseBackupList} from "@/features/database/components/database-backup-list"; +import {DatabaseRestoreList} from "@/features/database/components/database-restore-list"; import {MemberWithUser} from "@/db/schema/03_organization"; export type DatabaseTabsProps = { diff --git a/src/features/database/health-grid.tsx b/src/features/database/components/health-grid.tsx similarity index 100% rename from src/features/database/health-grid.tsx rename to src/features/database/components/health-grid.tsx diff --git a/src/features/database/health-modal.tsx b/src/features/database/components/health-modal.tsx similarity index 94% rename from src/features/database/health-modal.tsx rename to src/features/database/components/health-modal.tsx index ed646933..61bee4a5 100644 --- a/src/features/database/health-modal.tsx +++ b/src/features/database/components/health-modal.tsx @@ -11,7 +11,7 @@ import { SheetTrigger } from "@/components/ui/sheet"; import {HealthcheckLog} from "@/db/schema/15_healthcheck-log"; -import {HealthCheckGraph} from "@/features/database/health-grid"; +import {HealthCheckGraph} from "@/features/database/components/health-grid"; type HealthModalProps = { database: DatabaseWith, diff --git a/src/features/database/import-modal.tsx b/src/features/database/components/import-modal.tsx similarity index 94% rename from src/features/database/import-modal.tsx rename to src/features/database/components/import-modal.tsx index 6fe05a0c..43fb3813 100644 --- a/src/features/database/import-modal.tsx +++ b/src/features/database/components/import-modal.tsx @@ -13,7 +13,7 @@ import {Button} from "@/components/ui/button"; import {DatabaseWith} from "@/db/schema/07_database"; import {Separator} from "@/components/ui/separator"; import {Import} from "lucide-react"; -import {UploadBackupZone} from "@/features/database/import-upload-zone"; +import {UploadBackupZone} from "@/features/database/components/import-upload-zone"; type ImportModalProps = { database: DatabaseWith; diff --git a/src/features/database/import-upload-zone.tsx b/src/features/database/components/import-upload-zone.tsx similarity index 98% rename from src/features/database/import-upload-zone.tsx rename to src/features/database/components/import-upload-zone.tsx index 6773bc2b..2d7d2d43 100644 --- a/src/features/database/import-upload-zone.tsx +++ b/src/features/database/components/import-upload-zone.tsx @@ -7,7 +7,7 @@ import {useState} from "react"; import {Loader2} from "lucide-react"; import {ButtonWithLoading} from "@/components/common/button-with-loading"; import {toast} from "sonner"; -import {uploadBackupAction} from "@/features/database/import.action"; +import {uploadBackupAction} from "@/features/database/actions/import.action"; import {Card, CardContent} from "@/components/ui/card"; import {DatabaseWith} from "@/db/schema/07_database"; import {getFileHeadersBasedOnDbms} from "@/utils/common"; diff --git a/src/features/database/restore-columns.tsx b/src/features/database/components/restore-columns.tsx similarity index 98% rename from src/features/database/restore-columns.tsx rename to src/features/database/components/restore-columns.tsx index 0a963da7..00f63d79 100644 --- a/src/features/database/restore-columns.tsx +++ b/src/features/database/components/restore-columns.tsx @@ -19,12 +19,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { deleteRestoreAction, rerunRestorationAction, -} from "@/features/database/restore.action"; +} from "@/features/database/actions/restore.action"; import { toast } from "sonner"; import { TooltipCustom } from "@/components/common/tooltip-custom"; import { MemberWithUser } from "@/db/schema/03_organization"; import { ButtonWithConfirm } from "@/components/common/button-with-confirm"; -import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger"; +import {LogsModalTrigger} from "@/features/logs/components/logs-modal-trigger"; import {formatDuration} from "@/utils/text"; export function restoreColumns( diff --git a/src/features/database/components/retention-policy-form.tsx b/src/features/database/components/retention-policy-form.tsx new file mode 100644 index 00000000..3059e582 --- /dev/null +++ b/src/features/database/components/retention-policy-form.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + useZodForm, +} from "@/components/ui/form"; +import { + RetentionSettings, + RetentionSettingsSchema, +} from "@/features/database/schemas/retention-policy.schema"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Calendar, Save } from "lucide-react"; + +export type BackupRetentionSettingsFormProps = { + defaultValues?: RetentionSettings; + currentType?: string; + isPending?: boolean; + onSave: (values: RetentionSettings) => Promise; +}; + +export const BackupRetentionSettingsForm = ({ + defaultValues, + currentType, + isPending = false, + onSave, +}: BackupRetentionSettingsFormProps) => { + const form = useZodForm({ + schema: RetentionSettingsSchema, + defaultValues: defaultValues ?? { + count: 7, + days: 30, + gfs: { daily: 7, weekly: 4, monthly: 12, yearly: 3 }, + }, + }); + + const calculateTotalFiles = (values: RetentionSettings) => { + if (values.type === "gfs" && values.gfs) { + return ( + (values.gfs.daily ?? 0) + + (values.gfs.weekly ?? 0) + + (values.gfs.monthly ?? 0) + + (values.gfs.yearly ?? 0) + ); + } + return values.type === "count" + ? (values.count ?? 0) + : values.type === "days" + ? (values.days ?? 0) + : 0; + }; + + const getStorageEstimate = (totalFiles: number) => { + if (totalFiles <= 10) return "Low"; + if (totalFiles <= 30) return "Medium"; + return "High"; + }; + + return ( +
+
+
{ + await onSave(values); + }} + > + ( + + Retention Policy Type + + + {[ + { + id: "count", + label: "Keep last N backups", + desc: "Simple count-based retention (e.g., keep last 10 backups)", + }, + { + id: "days", + label: "Keep backups for X days", + desc: "Time-based retention (e.g., keep backups for 30 days)", + }, + { + id: "gfs", + label: "GFS Rotation", + desc: "Grandfather-Father-Son rotation for enterprise/critical systems", + badge: "Recommended", + }, + ].map((opt) => ( + + +
+ + {opt.label} + {opt.badge && ( + + {opt.badge} + + )} + +

+ {opt.desc} +

+
+ {currentType === opt.id && ( + + Actual + + )} +
+ ))} +
+
+ +
+ )} + /> + + {form.watch("type") && } + + {form.watch("type") === "count" && ( + ( + + Number of backups to keep + + field.onChange(e.target.valueAsNumber)} + /> + + + Older backups beyond this count will be automatically + deleted. + + + + )} + /> + )} + + {form.watch("type") === "days" && ( + ( + + Retention period (days) + + field.onChange(e.target.valueAsNumber)} + /> + + + Backups older than {field.value} days will be automatically + deleted. + + + + )} + /> + )} + + {form.watch("type") === "gfs" && ( +
+ {[ + { key: "daily" as const, label: "Daily", max: 31 }, + { key: "weekly" as const, label: "Weekly", max: 52 }, + { key: "monthly" as const, label: "Monthly", max: 120 }, + { key: "yearly" as const, label: "Yearly", max: 50 }, + ].map(({ key, label, max }) => ( + ( + + {label} backups + + + field.onChange(e.target.valueAsNumber) + } + /> + + Keep N {key} backups + + + )} + /> + ))} +
+ )} + + {form.watch("type") && ( + <> + +
+
+
+ + Storage Impact Summary +
+ {(() => { + const totalFiles = calculateTotalFiles(form.getValues()); + const estimate = getStorageEstimate(totalFiles); + return ( + + {estimate} Usage + + ); + })()} +
+
+
+ + Estimated files per database: + +

+ {calculateTotalFiles(form.getValues())} backup files +

+
+
+ Policy type: +

+ {form.watch("type") === "gfs" + ? "GFS Rotation" + : form.watch("type") === "count" + ? "Count-based" + : "Time-based"} +

+
+
+
+ + + )} + +
+
+ ); +}; diff --git a/src/features/database/components/retention-policy-sheet.tsx b/src/features/database/components/retention-policy-sheet.tsx new file mode 100644 index 00000000..b2d42a2c --- /dev/null +++ b/src/features/database/components/retention-policy-sheet.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Database, Ruler } from "lucide-react"; +import { DatabaseWith, RetentionPolicy } from "@/db/schema/07_database"; +import { BackupRetentionSettingsForm } from "@/features/database/components/retention-policy-form"; +import { RetentionSettings } from "@/features/database/schemas/retention-policy.schema"; +import { updateOrCreateBackupRetentionPolicyAction } from "@/features/database/actions/retention-policy.action"; + +type RetentionPolicySheetProps = { + database: DatabaseWith; +}; + +const toRetentionSettings = (rp: RetentionPolicy | undefined | null): RetentionSettings | undefined => { + if (!rp) return undefined; + return { + type: rp.type, + count: rp.count ?? 7, + days: rp.days ?? 30, + gfs: { + daily: rp.gfsDaily ?? 7, + weekly: rp.gfsWeekly ?? 4, + monthly: rp.gfsMonthly ?? 12, + yearly: rp.gfsYearly ?? 3, + }, + }; +}; + +export const RetentionPolicySheet = ({ database }: RetentionPolicySheetProps) => { + const queryClient = useQueryClient(); + const router = useRouter(); + + const mutation = useMutation({ + mutationFn: async (payload: RetentionSettings) => + updateOrCreateBackupRetentionPolicyAction({ databaseId: database.id, settings: payload }), + onSuccess: () => { + toast.success("Retention policy updated successfully."); + queryClient.invalidateQueries({ queryKey: ["database-data", database.id] }); + router.refresh(); + }, + onError: () => { + toast.error("An error occurred while updating retention policy."); + }, + }); + + return ( + + + + + + + + + Backup Retention Policy + + + Configure how long to keep your .dump backup files. Choose from simple count-based, time-based, + or enterprise GFS rotation strategies. + + + {database.backupPolicy !== null ? ( + { await mutation.mutateAsync(values); }} + /> + ) : ( +
+

+ No backup policy configured yet. Please configure one! +

+
+ )} +
+
+ ); +}; diff --git a/src/features/database/retention-policy.tsx b/src/features/database/components/retention-policy.tsx similarity index 99% rename from src/features/database/retention-policy.tsx rename to src/features/database/components/retention-policy.tsx index b9c9e7cb..b83caca1 100644 --- a/src/features/database/retention-policy.tsx +++ b/src/features/database/components/retention-policy.tsx @@ -13,7 +13,7 @@ import {useMutation, useQueryClient} from "@tanstack/react-query"; import {useRouter} from "next/navigation"; import { updateOrCreateBackupRetentionPolicyAction -} from "@/features/database/retention-policy.action"; +} from "@/features/database/actions/retention-policy.action"; import {Database as DbSchema} from "@/db/schema/07_database"; type RetentionPolicyType = "count" | "days" | "gfs" diff --git a/src/features/database/retention-policy-form.tsx b/src/features/database/retention-policy-form.tsx deleted file mode 100644 index d6c008eb..00000000 --- a/src/features/database/retention-policy-form.tsx +++ /dev/null @@ -1,306 +0,0 @@ -"use client" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, - useZodForm -} from "@/components/ui/form"; -import {RetentionSettings, RetentionSettingsSchema} from "@/features/database/retention-policy.schema"; -import {useMutation, useQueryClient} from "@tanstack/react-query"; -import {useRouter} from "next/navigation"; -import {updateOrCreateBackupRetentionPolicyAction} from "@/features/database/retention-policy.action"; -import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database"; -import {toast} from "sonner"; -import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group"; -import {Badge} from "@/components/ui/badge"; -import {Separator} from "@/components/ui/separator"; -import {Input} from "@/components/ui/input"; -import {Button} from "@/components/ui/button"; -import {Calendar, Save} from "lucide-react"; - -export type BackupRetentionSettingsFormProps = { - defaultValues?: RetentionPolicy; - database: DatabaseWith; -}; - -export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => { - const queryClient = useQueryClient(); - const router = useRouter(); - - const defaultValuesFormatted: RetentionSettings = { - type: defaultValues?.type, - count: defaultValues?.count ?? 7, - days: defaultValues?.days ?? 30, - gfs: { - daily: defaultValues?.gfsDaily ?? 7, - weekly: defaultValues?.gfsWeekly ?? 4, - monthly: defaultValues?.gfsMonthly ?? 12, - yearly: defaultValues?.gfsYearly ?? 3, - }, - }; - - const form = useZodForm({ - schema: RetentionSettingsSchema, - defaultValues: defaultValuesFormatted, - }); - - const mutation = useMutation({ - mutationFn: async (payload: RetentionSettings) => - await updateOrCreateBackupRetentionPolicyAction({ - databaseId: database.id, - settings: payload, - }), - onSuccess: () => { - toast.success("Retention policy updated successfully."); - queryClient.invalidateQueries({queryKey: ["database-data", database.id]}); - router.refresh(); - }, - onError: () => { - toast.error("An error occurred while updating retention policy."); - }, - }); - - const calculateTotalFiles = (values: RetentionSettings) => { - if (values.type === "gfs" && values.gfs) { - return ( - (values.gfs.daily ?? 0) + - (values.gfs.weekly ?? 0) + - (values.gfs.monthly ?? 0) + - (values.gfs.yearly ?? 0) - ); - } - return values.type === "count" ? values.count ?? 0 : values.type === "days" ? values.days ?? 0 : 0; - }; - - const getStorageEstimate = (totalFiles: number) => { - if (totalFiles <= 10) return "Low"; - if (totalFiles <= 30) return "Medium"; - return "High"; - }; - - return ( -
-
-
{ - await mutation.mutateAsync(values); - }} - > - ( - - Retention Policy Type - - - {[ - { - id: "count", - label: "Keep last N backups", - desc: "Simple count-based retention (e.g., keep last 10 backups)", - }, - { - id: "days", - label: "Keep backups for X days", - desc: "Time-based retention (e.g., keep backups for 30 days)", - }, - { - id: "gfs", - label: "GFS Rotation", - desc: "Grandfather-Father-Son rotation for enterprise/critical systems", - badge: "Recommended", - }, - ].map((opt) => ( - - -
- - {opt.label} - {opt.badge && ( - - {opt.badge} - - )} - -

{opt.desc}

-
- - {database.retentionPolicy?.type === opt.id && ( - - Actual - - )} -
- ))} -
-
- -
- )} - /> - - {form.watch("type") && ( - - )} - - - {form.watch("type") === "count" && ( - ( - - Number of backups to keep - - field.onChange(e.target.valueAsNumber)} - /> - - - Older backups beyond this count will be automatically deleted. - - - - )} - /> - )} - - {form.watch("type") === "days" && ( - ( - - Retention period (days) - - field.onChange(e.target.valueAsNumber)} - /> - - - Backups older than {field.value} days will be automatically deleted. - - - - )} - /> - )} - - {form.watch("type") === "gfs" && ( -
- {["daily", "weekly", "monthly", "yearly"].map((key) => ( - ( - - - {key.charAt(0).toUpperCase() + key.slice(1)} backups - - - field.onChange(e.target.valueAsNumber)} - /> - - - Keep N {key} backups - - - - )} - /> - ))} -
- )} - - {form.watch("type") && ( - <> - -
-
-
- - Storage Impact Summary -
- {(() => { - const totalFiles = calculateTotalFiles(form.getValues()); - const estimate = getStorageEstimate(totalFiles); - return ( - - {estimate} Usage - - ); - })()} -
-
-
- Estimated files per database: -

- {calculateTotalFiles(form.getValues())} backup files -

-
-
- Policy type: -

- {form.watch("type") === "gfs" - ? "GFS Rotation" - : form.watch("type") === "count" - ? "Count-based" - : "Time-based"} -

-
-
-
- - - )} - -
-
- ); -}; diff --git a/src/features/database/retention-policy-sheet.tsx b/src/features/database/retention-policy-sheet.tsx deleted file mode 100644 index 5ee0dafb..00000000 --- a/src/features/database/retention-policy-sheet.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from "@/components/ui/sheet"; -import {Button} from "@/components/ui/button"; -import {Database, Ruler} from "lucide-react"; -import {DatabaseWith as DbSchema, RetentionPolicy} from "@/db/schema/07_database"; -import { - BackupRetentionSettingsForm -} from "@/features/database/retention-policy-form"; - -type RetentionPolicySheetProps = { - database: DbSchema -} - -export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => { - return ( - - - - - - - - - Backup Retention Policy - - - Configure how long to keep your .dump backup files. Choose from simple count-based, time-based, - or - enterprise GFS rotation strategies. - - - {database.backupPolicy !== null ? - - : -
-

- No backup policy configured yet. Please configure one ! -

-
- } -
-
- ) -} \ No newline at end of file diff --git a/src/features/database/backup-actions.schema.ts b/src/features/database/schemas/backup-actions.schema.ts similarity index 100% rename from src/features/database/backup-actions.schema.ts rename to src/features/database/schemas/backup-actions.schema.ts diff --git a/src/features/database/channels-policy.schema.ts b/src/features/database/schemas/channels-policy.schema.ts similarity index 100% rename from src/features/database/channels-policy.schema.ts rename to src/features/database/schemas/channels-policy.schema.ts diff --git a/src/features/database/database-form.schema.ts b/src/features/database/schemas/database-form.schema.ts similarity index 100% rename from src/features/database/database-form.schema.ts rename to src/features/database/schemas/database-form.schema.ts diff --git a/src/features/database/retention-policy.schema.ts b/src/features/database/schemas/retention-policy.schema.ts similarity index 100% rename from src/features/database/retention-policy.schema.ts rename to src/features/database/schemas/retention-policy.schema.ts diff --git a/src/features/layout/app-sidebar.tsx b/src/features/layout/components/app-sidebar.tsx similarity index 73% rename from src/features/layout/app-sidebar.tsx rename to src/features/layout/components/app-sidebar.tsx index 82455d56..b302b347 100644 --- a/src/features/layout/app-sidebar.tsx +++ b/src/features/layout/components/app-sidebar.tsx @@ -6,12 +6,12 @@ import { SidebarMenu, SidebarMenuItem } from "@/components/ui/sidebar"; -import {SidebarLogo} from "@/features/layout/logo-sidebar"; -import {SidebarMenuCustomMain} from "@/features/layout/menu-sidebar-main"; -import {SideBarFooterCredit} from "@/features/layout/side-bar-footer-credit"; -import {OrganizationCombobox} from "@/features/organizations/organization-combobox"; +import {SidebarLogo} from "@/features/layout/components/logo-sidebar"; +import {SidebarMenuCustomMain} from "@/features/layout/components/menu-sidebar-main"; +import {SideBarFooterCredit} from "@/features/layout/components/side-bar-footer-credit"; +import {OrganizationCombobox} from "@/features/organizations/components/organization-combobox"; import {env} from "@/env.mjs"; -import {LoggedInButton} from "@/features/layout/logged-in-button.server"; +import {LoggedInButton} from "@/features/layout/components/logged-in-button.server"; export function AppSidebar({ updateNotification }: { updateNotification?: ReactNode } = {}) { const projectName = env.PROJECT_NAME; diff --git a/src/features/layout/card-auth.tsx b/src/features/layout/components/card-auth.tsx similarity index 100% rename from src/features/layout/card-auth.tsx rename to src/features/layout/components/card-auth.tsx diff --git a/src/features/layout/demo-reset-banner.tsx b/src/features/layout/components/demo-reset-banner.tsx similarity index 100% rename from src/features/layout/demo-reset-banner.tsx rename to src/features/layout/components/demo-reset-banner.tsx diff --git a/src/features/layout/header.tsx b/src/features/layout/components/header.tsx similarity index 86% rename from src/features/layout/header.tsx rename to src/features/layout/components/header.tsx index ed3d0f7b..dd0dd8bb 100644 --- a/src/features/layout/header.tsx +++ b/src/features/layout/components/header.tsx @@ -4,9 +4,9 @@ import {SidebarTrigger} from "@/components/ui/sidebar"; import {currentUser} from "@/lib/auth/current-user"; import {BreadCrumbsWrapper} from "@/components/common/bread-crumbs"; import GitHubStarsButtonCustom from "@/components/common/github-button"; -import {LoggedInButton} from "@/features/layout/logged-in-button.server"; +import {LoggedInButton} from "@/features/layout/components/logged-in-button.server"; import { env } from "@/env.mjs"; -import { DemoResetBanner } from "@/features/layout/demo-reset-banner"; +import { DemoResetBanner } from "@/features/layout/components/demo-reset-banner"; export const Header = async ({ actions }: { actions?: ReactNode } = {}) => { const user = await currentUser(); diff --git a/src/features/layout/logged-in-button.server.tsx b/src/features/layout/components/logged-in-button.server.tsx similarity index 70% rename from src/features/layout/logged-in-button.server.tsx rename to src/features/layout/components/logged-in-button.server.tsx index 091c4f62..2ee4ce2d 100644 --- a/src/features/layout/logged-in-button.server.tsx +++ b/src/features/layout/components/logged-in-button.server.tsx @@ -3,16 +3,18 @@ import { getAccounts, getSession, getSessions } from "@/lib/auth/auth"; import { LoggedInButtonClient } from "./logged-in-button"; import { SUPPORTED_PROVIDERS } from "@/lib/auth/config"; import { env } from "@/env.mjs"; +import { resolveAvatarUrl } from "@/utils/resolve-avatar-url"; export const LoggedInButton = async () => { - const user = await currentUser(); - const sessions = await getSessions(); - const currentSession = await getSession(); - const accounts = await getAccounts(); + const [user, sessions, currentSession, accounts] = await Promise.all([ + currentUser(), + getSessions(), + getSession(), + getAccounts(), + ]); if (!user) return null; - return ( { accounts={accounts} providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)} apiEnabled={env.API_ENABLED} + avatarUrl={resolveAvatarUrl(user)} /> ); }; diff --git a/src/features/layout/logged-in-button.tsx b/src/features/layout/components/logged-in-button.tsx similarity index 84% rename from src/features/layout/logged-in-button.tsx rename to src/features/layout/components/logged-in-button.tsx index 662a22a3..44deecf7 100644 --- a/src/features/layout/logged-in-button.tsx +++ b/src/features/layout/components/logged-in-button.tsx @@ -6,7 +6,7 @@ import { SidebarMenuButton } from "@/components/ui/sidebar"; import { LoggedInDropdown } from "./logged-in-dropdown"; import { Account, Session } from "better-auth"; import { AuthProviderConfig } from "@/lib/auth/config"; -import {User} from "@/db/schema/02_user"; +import { User } from "@/db/schema/02_user"; type LoggedInButtonClientProps = { user: User; @@ -15,9 +15,10 @@ type LoggedInButtonClientProps = { accounts: Account[]; providers: AuthProviderConfig[]; apiEnabled: boolean; + avatarUrl?: string; }; -export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers, apiEnabled }: LoggedInButtonClientProps) => { +export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers, apiEnabled, avatarUrl }: LoggedInButtonClientProps) => { return (
- + {(user.name?.[0] ?? user.email?.[0] ?? "?").toUpperCase()} - {user.image && } + {avatarUrl && }
{user.name} diff --git a/src/features/layout/logged-in-dropdown.tsx b/src/features/layout/components/logged-in-dropdown.tsx similarity index 94% rename from src/features/layout/logged-in-dropdown.tsx rename to src/features/layout/components/logged-in-dropdown.tsx index 42012a26..49432ac7 100644 --- a/src/features/layout/logged-in-dropdown.tsx +++ b/src/features/layout/components/logged-in-dropdown.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { LogOut, User } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { signOut } from "@/lib/auth/auth-client"; -import { ProfileModal } from "@/features/layout/profile-modal"; +import { ProfileModal } from "@/features/layout/components/profile-modal"; import { Account, Session, User as UserType } from "@/db/schema/02_user"; import { AuthProviderConfig } from "@/lib/auth/config"; @@ -17,11 +17,11 @@ export type LoggedInDropdownProps = PropsWithChildren<{ children: ReactNode; providers: AuthProviderConfig[]; apiEnabled: boolean; + avatarUrl?: string; }>; -export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers, apiEnabled }: LoggedInDropdownProps) => { +export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers, apiEnabled, avatarUrl }: LoggedInDropdownProps) => { const router = useRouter(); - const [isModalOpen, setIsModalOpen] = useState(false); return ( @@ -35,6 +35,7 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi onOpenChange={setIsModalOpen} providers={providers} apiEnabled={apiEnabled} + avatarUrl={avatarUrl} /> {children} diff --git a/src/features/layout/logo-sidebar.tsx b/src/features/layout/components/logo-sidebar.tsx similarity index 100% rename from src/features/layout/logo-sidebar.tsx rename to src/features/layout/components/logo-sidebar.tsx diff --git a/src/features/layout/menu-sidebar-main.tsx b/src/features/layout/components/menu-sidebar-main.tsx similarity index 99% rename from src/features/layout/menu-sidebar-main.tsx rename to src/features/layout/components/menu-sidebar-main.tsx index c161fc2f..403eb850 100644 --- a/src/features/layout/menu-sidebar-main.tsx +++ b/src/features/layout/components/menu-sidebar-main.tsx @@ -10,7 +10,7 @@ import { Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen, Hammer, ChevronsLeftRightEllipsis } from "lucide-react"; -import {SidebarGroupItem, SidebarMenuCustomBase} from "@/features/layout/menu-sidebar"; +import {SidebarGroupItem, SidebarMenuCustomBase} from "@/features/layout/components/menu-sidebar"; import {authClient} from "@/lib/auth/auth-client"; diff --git a/src/features/layout/menu-sidebar.tsx b/src/features/layout/components/menu-sidebar.tsx similarity index 100% rename from src/features/layout/menu-sidebar.tsx rename to src/features/layout/components/menu-sidebar.tsx diff --git a/src/features/layout/page.tsx b/src/features/layout/components/page.tsx similarity index 100% rename from src/features/layout/page.tsx rename to src/features/layout/components/page.tsx diff --git a/src/features/layout/profile-modal.tsx b/src/features/layout/components/profile-modal.tsx similarity index 83% rename from src/features/layout/profile-modal.tsx rename to src/features/layout/components/profile-modal.tsx index c1925a0e..345dd568 100644 --- a/src/features/layout/profile-modal.tsx +++ b/src/features/layout/components/profile-modal.tsx @@ -5,11 +5,11 @@ import { Tabs, TabsContent } from "@/components/ui/tabs"; import { ProfileSidebar } from "./profile-sidebar"; import type { AuthProviderConfig } from "@/lib/auth/config"; import { User, Session, Account } from "@/db/schema/02_user"; -import { ProfileGeneral } from "@/features/profile/profile-general"; -import { ProfileSecurity } from "@/features/profile/profile-security"; -import { ProfileProviders } from "@/features/profile/profile-providers"; -import { ProfileAccount } from "@/features/profile/profile-account"; -import { ProfileAppearance } from "@/features/profile/profile-appearance"; +import { ProfileGeneral } from "@/features/profile/components/profile-general"; +import { ProfileSecurity } from "@/features/profile/components/profile-security"; +import { ProfileProviders } from "@/features/profile/components/profile-providers"; +import { ProfileAccount } from "@/features/profile/components/profile-account"; +import { ProfileAppearance } from "@/features/profile/components/profile-appearance"; type ProfileModalProps = { open: boolean; @@ -20,9 +20,10 @@ type ProfileModalProps = { onOpenChange: (open: boolean) => void; providers: AuthProviderConfig[]; apiEnabled: boolean; + avatarUrl?: string; }; -export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers, apiEnabled }: ProfileModalProps) => { +export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers, apiEnabled, avatarUrl }: ProfileModalProps) => { return ( @@ -35,7 +36,7 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
- + diff --git a/src/features/layout/profile-sidebar.tsx b/src/features/layout/components/profile-sidebar.tsx similarity index 100% rename from src/features/layout/profile-sidebar.tsx rename to src/features/layout/components/profile-sidebar.tsx diff --git a/src/features/layout/side-bar-footer-credit.tsx b/src/features/layout/components/side-bar-footer-credit.tsx similarity index 100% rename from src/features/layout/side-bar-footer-credit.tsx rename to src/features/layout/components/side-bar-footer-credit.tsx diff --git a/src/features/logs/log-row-modal.tsx b/src/features/logs/components/log-row-modal.tsx similarity index 100% rename from src/features/logs/log-row-modal.tsx rename to src/features/logs/components/log-row-modal.tsx diff --git a/src/features/logs/logs-modal-context.tsx b/src/features/logs/components/logs-modal-context.tsx similarity index 100% rename from src/features/logs/logs-modal-context.tsx rename to src/features/logs/components/logs-modal-context.tsx diff --git a/src/features/logs/logs-modal-trigger.tsx b/src/features/logs/components/logs-modal-trigger.tsx similarity index 86% rename from src/features/logs/logs-modal-trigger.tsx rename to src/features/logs/components/logs-modal-trigger.tsx index a39acee1..c32f3f16 100644 --- a/src/features/logs/logs-modal-trigger.tsx +++ b/src/features/logs/components/logs-modal-trigger.tsx @@ -1,7 +1,7 @@ import {FileText} from "lucide-react"; import {JobLog} from "@/db/schema/17_job-log"; import {Button} from "@/components/ui/button"; -import {useLogsModal} from "@/features/logs/logs-modal-context"; +import {useLogsModal} from "@/features/logs/components/logs-modal-context"; export type LogsModalTriggerProps = { logs: JobLog[] diff --git a/src/features/logs/logs-modal.tsx b/src/features/logs/components/logs-modal.tsx similarity index 91% rename from src/features/logs/logs-modal.tsx rename to src/features/logs/components/logs-modal.tsx index 5697b0dc..d43c1d6d 100644 --- a/src/features/logs/logs-modal.tsx +++ b/src/features/logs/components/logs-modal.tsx @@ -6,9 +6,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { useLogsModal } from "@/features/logs/logs-modal-context" +import { useLogsModal } from "@/features/logs/components/logs-modal-context" import { JobLog } from "@/db/schema/17_job-log" -import { LogRow } from "@/features/logs/log-row-modal" +import { LogRow } from "@/features/logs/components/log-row-modal" export const LogsModal = () => { const { open, logs, closeModal } = useLogsModal() diff --git a/src/features/logs/types.ts b/src/features/logs/types/index.ts similarity index 100% rename from src/features/logs/types.ts rename to src/features/logs/types/index.ts diff --git a/src/features/migration/migration.action.ts b/src/features/migration/actions/migration.action.ts similarity index 98% rename from src/features/migration/migration.action.ts rename to src/features/migration/actions/migration.action.ts index 1dabf9cd..23e9cfff 100644 --- a/src/features/migration/migration.action.ts +++ b/src/features/migration/actions/migration.action.ts @@ -5,7 +5,7 @@ import {z} from "zod"; import {db} from "@/db"; import * as drizzleDb from "@/db"; import {eq, inArray} from "drizzle-orm"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; +import {dispatchStorage} from "@/features/storages/utils/storages.dispatch"; import {v4 as uuidv4} from "uuid"; import {getTodayISODate} from "@/utils/date-formatting"; import {withUpdatedAt} from "@/db/utils"; diff --git a/src/features/migration/migration-flow.tsx b/src/features/migration/components/migration-flow.tsx similarity index 100% rename from src/features/migration/migration-flow.tsx rename to src/features/migration/components/migration-flow.tsx diff --git a/src/features/migration/migration-tool.tsx b/src/features/migration/components/migration-tool.tsx similarity index 95% rename from src/features/migration/migration-tool.tsx rename to src/features/migration/components/migration-tool.tsx index f51ad229..06847a92 100644 --- a/src/features/migration/migration-tool.tsx +++ b/src/features/migration/components/migration-tool.tsx @@ -2,13 +2,13 @@ import {useState, useMemo} from "react" import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project" -import {SourcePanel} from "@/features/migration/source-panel" +import {SourcePanel} from "@/features/migration/components/source-panel" import {Backup, DatabaseWith} from "@/db/schema/07_database" -import {MigrationFlow, MigrationStatus} from "@/features/migration/migration-flow" -import {TargetPanel} from "@/features/migration/target-panel" +import {MigrationFlow, MigrationStatus} from "@/features/migration/components/migration-flow" +import {TargetPanel} from "@/features/migration/components/target-panel" import {useMutation} from "@tanstack/react-query"; import {toast} from "sonner"; -import {migrationAction} from "@/features/migration/migration.action"; +import {migrationAction} from "@/features/migration/actions/migration.action"; import {useRouter} from "next/navigation"; interface MigrationToolProps { diff --git a/src/features/migration/source-panel.tsx b/src/features/migration/components/source-panel.tsx similarity index 100% rename from src/features/migration/source-panel.tsx rename to src/features/migration/components/source-panel.tsx diff --git a/src/features/migration/target-panel.tsx b/src/features/migration/components/target-panel.tsx similarity index 100% rename from src/features/migration/target-panel.tsx rename to src/features/migration/components/target-panel.tsx diff --git a/src/features/notifications/notification-log-columns.tsx b/src/features/notifications/components/notification-log-columns.tsx similarity index 95% rename from src/features/notifications/notification-log-columns.tsx rename to src/features/notifications/components/notification-log-columns.tsx index e72fc8e5..6b924259 100644 --- a/src/features/notifications/notification-log-columns.tsx +++ b/src/features/notifications/components/notification-log-columns.tsx @@ -5,8 +5,8 @@ import {NotificationLogWithRelations} from "@/db/services/notification-log"; import {humanReadableDate} from "@/utils/date-formatting"; import {CheckCircle2, XCircle} from "lucide-react"; import {Badge} from "@/components/ui/badge"; -import {NotificationLogModal} from "@/features/notifications/notification-log-modal"; -import {getChannelIcon} from "@/features/channel/channels-helpers"; +import {NotificationLogModal} from "@/features/notifications/components/notification-log-modal"; +import {getChannelIcon} from "@/features/channel/components/channels-helpers"; export function notificationLogsColumns(): ColumnDef[] { diff --git a/src/features/notifications/notification-log-modal.tsx b/src/features/notifications/components/notification-log-modal.tsx similarity index 100% rename from src/features/notifications/notification-log-modal.tsx rename to src/features/notifications/components/notification-log-modal.tsx diff --git a/src/features/notifications/notification-logs-list.tsx b/src/features/notifications/components/notification-logs-list.tsx similarity index 88% rename from src/features/notifications/notification-logs-list.tsx rename to src/features/notifications/components/notification-logs-list.tsx index f0766896..5f5d6ac4 100644 --- a/src/features/notifications/notification-logs-list.tsx +++ b/src/features/notifications/components/notification-logs-list.tsx @@ -2,7 +2,7 @@ import {DataTable} from "@/components/common/data-table"; import { notificationLogsColumns, -} from "@/features/notifications/notification-log-columns"; +} from "@/features/notifications/components/notification-log-columns"; import {NotificationLogWithRelations} from "@/db/services/notification-log"; diff --git a/src/features/notifications/notifications.types.ts b/src/features/notifications/types/index.ts similarity index 100% rename from src/features/notifications/notifications.types.ts rename to src/features/notifications/types/index.ts diff --git a/src/features/notifications/notifications.dispatch.ts b/src/features/notifications/utils/notifications.dispatch.ts similarity index 97% rename from src/features/notifications/notifications.dispatch.ts rename to src/features/notifications/utils/notifications.dispatch.ts index 2a5cc293..f255973e 100644 --- a/src/features/notifications/notifications.dispatch.ts +++ b/src/features/notifications/utils/notifications.dispatch.ts @@ -1,7 +1,7 @@ "use server"; import {eq} from "drizzle-orm"; -import {dispatchViaProvider} from "@/features/channel/notifications"; -import type {EventPayload, DispatchResult, EventKind} from "@/features/notifications/notifications.types"; +import {dispatchViaProvider} from "@/features/channel/components/notifications"; +import type {EventPayload, DispatchResult, EventKind} from "@/features/notifications/types"; import * as drizzleDb from "@/db"; import {db} from "@/db"; import {notificationLog} from "@/db/schema/11_notification-log"; diff --git a/src/features/notifications/notifications.helpers.ts b/src/features/notifications/utils/notifications.helpers.ts similarity index 96% rename from src/features/notifications/notifications.helpers.ts rename to src/features/notifications/utils/notifications.helpers.ts index 5b5ee1e9..6984086d 100644 --- a/src/features/notifications/notifications.helpers.ts +++ b/src/features/notifications/utils/notifications.helpers.ts @@ -1,6 +1,6 @@ import { DatabaseWith } from "@/db/schema/07_database"; -import { EventKind, EventPayload } from "@/features/notifications/notifications.types"; -import { dispatchNotification } from "@/features/notifications/notifications.dispatch"; +import { EventKind, EventPayload } from "@/features/notifications/types"; +import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch"; import {db} from "@/db"; import {eq} from "drizzle-orm"; import * as drizzleDb from "@/db"; diff --git a/src/features/onboarding/actions/apply-db-settings.action.ts b/src/features/onboarding/actions/apply-db-settings.action.ts new file mode 100644 index 00000000..1151a926 --- /dev/null +++ b/src/features/onboarding/actions/apply-db-settings.action.ts @@ -0,0 +1,108 @@ +"use server"; + +import { db } from "@/db"; +import * as drizzleDb from "@/db"; +import { eq } from "drizzle-orm"; +import { userAction } from "@/lib/safe-actions/actions"; +import { ApplyDbSettingsSchema } from "@/features/onboarding/schemas/db-settings.schema"; + +export const applyOnboardingDbSettingsAction = userAction + .schema(ApplyDbSettingsSchema) + .action(async ({ parsedInput }) => { + const { + databaseId, + section, + retention, + backupMethod, + backupCron, + notificationPolicies, + storagePolicies, + } = parsedInput; + + const applyRetention = async () => { + if (!retention) return; + const existing = await db + .select() + .from(drizzleDb.schemas.retentionPolicy) + .where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId)) + .limit(1); + + const values = { + type: retention.type ?? "gfs", + count: retention.count, + days: retention.days, + gfsDaily: retention.gfs.daily, + gfsWeekly: retention.gfs.weekly, + gfsMonthly: retention.gfs.monthly, + gfsYearly: retention.gfs.yearly, + }; + + if (existing.length > 0) { + await db + .update(drizzleDb.schemas.retentionPolicy) + .set({ ...values, updatedAt: new Date() }) + .where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId)); + } else { + await db + .insert(drizzleDb.schemas.retentionPolicy) + .values({ databaseId, ...values }); + } + }; + + const applyScheduling = async () => { + if (backupMethod === undefined) return; + const cronValue = + backupMethod === "manual" ? null : (backupCron ?? "0 0 * * *"); + await db + .update(drizzleDb.schemas.database) + .set({ backupPolicy: cronValue, updatedAt: new Date() }) + .where(eq(drizzleDb.schemas.database.id, databaseId)); + }; + + const applyNotifications = async () => { + if (!notificationPolicies) return; + await db.transaction(async (tx) => { + await tx + .delete(drizzleDb.schemas.alertPolicy) + .where(eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId)); + + if (notificationPolicies.length > 0) { + await tx.insert(drizzleDb.schemas.alertPolicy).values( + notificationPolicies.map((p) => ({ + databaseId, + notificationChannelId: p.channelId, + eventKinds: p.eventKinds as any, + enabled: p.enabled, + })), + ); + } + }); + }; + + const applyStorage = async () => { + if (!storagePolicies) return; + await db.transaction(async (tx) => { + await tx + .delete(drizzleDb.schemas.storagePolicy) + .where(eq(drizzleDb.schemas.storagePolicy.databaseId, databaseId)); + + if (storagePolicies.length > 0) { + await tx.insert(drizzleDb.schemas.storagePolicy).values( + storagePolicies.map((p) => ({ + databaseId, + storageChannelId: p.channelId, + enabled: p.enabled, + })), + ); + } + }); + }; + + if (section === "retention" || section === "all") await applyRetention(); + if (section === "scheduling" || section === "all") await applyScheduling(); + if (section === "notifications" || section === "all") + await applyNotifications(); + if (section === "storage" || section === "all") await applyStorage(); + + return { success: true }; + }); diff --git a/src/features/onboarding/actions/generate-passkey-context.action.ts b/src/features/onboarding/actions/generate-passkey-context.action.ts new file mode 100644 index 00000000..09cfaa64 --- /dev/null +++ b/src/features/onboarding/actions/generate-passkey-context.action.ts @@ -0,0 +1,11 @@ +"use server"; + +import { z } from "zod"; +import { action } from "@/lib/safe-actions/actions"; +import { signPasskeyContext } from "@/lib/auth/passkey-context"; + +export const generatePasskeyContextAction = action + .schema(z.object({ name: z.string().min(1), email: z.email() })) + .action(async ({ parsedInput }) => { + return signPasskeyContext(parsedInput.name, parsedInput.email); + }); diff --git a/src/features/onboarding/actions/get-agent-status.action.ts b/src/features/onboarding/actions/get-agent-status.action.ts new file mode 100644 index 00000000..3b10e81a --- /dev/null +++ b/src/features/onboarding/actions/get-agent-status.action.ts @@ -0,0 +1,17 @@ +"use server"; + +import { userAction } from "@/lib/safe-actions/actions"; +import { z } from "zod"; +import { getAgentAction } from "@/features/agents/actions/agents.action"; + +export const getAgentStatusAction = userAction + .schema(z.object({ agentId: z.string() })) + .action(async ({ parsedInput }) => { + const result = await getAgentAction(parsedInput.agentId); + if (!result?.data?.data) return { connected: false }; + const agent = result.data.data; + const lastContact = agent.lastContact ? new Date(agent.lastContact) : null; + const connected = + lastContact !== null && Date.now() - lastContact.getTime() < 60_000; + return { connected }; + }); diff --git a/src/features/onboarding/actions/onboarding-mark-done.action.ts b/src/features/onboarding/actions/onboarding-mark-done.action.ts new file mode 100644 index 00000000..538d42ef --- /dev/null +++ b/src/features/onboarding/actions/onboarding-mark-done.action.ts @@ -0,0 +1,21 @@ +"use server"; + +import { userAction } from "@/lib/safe-actions/actions"; +import { z } from "zod"; +import { db } from "@/db"; +import * as drizzleDb from "@/db"; +import { eq } from "drizzle-orm"; + +export const markOnboardingDoneAction = userAction + .schema(z.object({})) + .action(async () => { + const settings = await db.query.setting.findFirst(); + if (!settings) { + throw new Error("Settings not found"); + } + await db + .update(drizzleDb.schemas.setting) + .set({ onboarding: true }) + .where(eq(drizzleDb.schemas.setting.id, settings.id)); + return { done: true }; + }); diff --git a/src/features/onboarding/actions/update-account.action.ts b/src/features/onboarding/actions/update-account.action.ts new file mode 100644 index 00000000..f9b062ae --- /dev/null +++ b/src/features/onboarding/actions/update-account.action.ts @@ -0,0 +1,24 @@ +"use server"; + +import { userAction } from "@/lib/safe-actions/actions"; +import { z } from "zod"; +import { db } from "@/db"; +import * as drizzleDb from "@/db"; +import { eq } from "drizzle-orm"; + +export const updateAccountAction = userAction + .schema( + z.object({ + firstName: z.string().min(1), + lastName: z.string().min(1), + }), + ) + .action(async ({ parsedInput, ctx }) => { + const name = `${parsedInput.firstName} ${parsedInput.lastName}`.trim(); + const [updated] = await db + .update(drizzleDb.schemas.user) + .set({ name, updatedAt: new Date() }) + .where(eq(drizzleDb.schemas.user.id, ctx.user.id)) + .returning(); + return { user: updated }; + }); diff --git a/src/features/onboarding/components/backup-schedule-selector.tsx b/src/features/onboarding/components/backup-schedule-selector.tsx new file mode 100644 index 00000000..3d76d222 --- /dev/null +++ b/src/features/onboarding/components/backup-schedule-selector.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { cn } from "@/lib/utils"; +import { isValidCronPart } from "@/utils/cron"; + +export type BackupScheduleValue = { + method: "manual" | "automatic"; + cron?: string; +}; + +const PRESETS = [ + { label: "Hourly", sub: "Every hour", cron: "0 * * * *" }, + { label: "Every 6h", sub: "4× per day", cron: "0 */6 * * *" }, + { label: "Every 12h", sub: "2× per day", cron: "0 */12 * * *"}, + { label: "Daily", sub: "Every day at midnight",cron: "0 0 * * *" }, + { label: "Weekly", sub: "Every Sunday", cron: "0 0 * * 0" }, + { label: "Monthly", sub: "1st of each month", cron: "0 0 1 * *" }, +] as const; + +type PresetCron = (typeof PRESETS)[number]["cron"]; + +function isPresetCron(cron: string | undefined): cron is PresetCron { + return PRESETS.some((p) => p.cron === cron); +} + +function validateCron(expr: string): boolean { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return false; + const types = ["minute", "hour", "day-of-month", "month", "day-of-week"] as const; + return parts.every((p, i) => isValidCronPart(types[i], p)); +} + +type Props = { + value: BackupScheduleValue; + onChange: (value: BackupScheduleValue) => void; +}; + +export const BackupScheduleSelector = ({ value, onChange }: Props) => { + const isCustom = !!value.cron && !isPresetCron(value.cron); + const [customInput, setCustomInput] = useState( + isCustom ? (value.cron ?? "") : "", + ); + const [customError, setCustomError] = useState(null); + + const handleMethodChange = (method: "manual" | "automatic") => { + onChange({ + method, + cron: method === "manual" ? undefined : (value.cron ?? "0 0 * * *"), + }); + }; + + const handlePresetClick = (cron: PresetCron) => { + setCustomError(null); + onChange({ ...value, cron }); + }; + + const handleCustomChange = (raw: string) => { + setCustomInput(raw); + if (validateCron(raw)) { + setCustomError(null); + onChange({ ...value, cron: raw.trim() }); + } else { + setCustomError("Invalid cron expression"); + } + }; + + const handleCustomFocus = () => { + if (!customInput && value.cron && isPresetCron(value.cron)) { + setCustomInput(value.cron); + } + }; + + return ( +
+ handleMethodChange(m as "manual" | "automatic")} + className="grid grid-cols-2 gap-3" + > + {( + [ + { id: "manual", label: "Manual", desc: "Trigger backups manually" }, + { id: "automatic", label: "Automatic", desc: "Scheduled via cron" }, + ] as const + ).map((opt) => ( + + ))} + + + {value.method === "automatic" && ( +
+ + +
+ {PRESETS.map((p) => ( + + ))} +
+ +
+ + handleCustomChange(e.target.value)} + className={cn( + "font-mono text-sm", + isCustom && !customError && "border-primary", + customError && "border-destructive", + )} + /> + {customError ? ( +

{customError}

+ ) : isCustom ? ( +

{value.cron}

+ ) : null} +
+ +
+ {value.cron ?? "0 0 * * *"} +
+
+ )} +
+ ); +}; diff --git a/src/features/onboarding/components/db-settings/db-detail.tsx b/src/features/onboarding/components/db-settings/db-detail.tsx new file mode 100644 index 00000000..9cd43482 --- /dev/null +++ b/src/features/onboarding/components/db-settings/db-detail.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { + ArrowLeft, + Bell, + Check, + Clock, + Copy, + Database, + HardDrive, + Shield, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { + OnboardingDatabase, + SectionKind, +} from "@/features/onboarding/types"; + +const SECTIONS: { + kind: SectionKind; + label: string; + description: string; + icon: React.ReactNode; +}[] = [ + { + kind: "retention", + label: "Retention Policy", + description: "Configure how long your data is retained", + icon: , + }, + { + kind: "scheduling", + label: "Scheduling", + description: "Set up backup schedules for your database", + icon: , + }, + { + kind: "notifications", + label: "Notifications", + description: "Get notified about backup status and issues", + icon: , + }, + { + kind: "storage", + label: "Storage", + description: "Choose where to store your backups", + icon: , + }, +]; + +type DbDetailProps = { + db: OnboardingDatabase | undefined; + dbId: string; + isSectionConfigured: (section: SectionKind) => boolean; + isMultiDb: boolean; + hasAnyConfigured: boolean; + isApplyingToAll: boolean; + onSelectSection: (section: SectionKind) => void; + onApplyToAll: () => Promise; + onBack: () => void; +}; + +export const DbDetail = ({ + db, + dbId, + isSectionConfigured, + isMultiDb, + hasAnyConfigured, + isApplyingToAll, + onSelectSection, + onApplyToAll, + onBack, +}: DbDetailProps) => ( +
+
+
+ +
+

+ {db?.name ?? dbId}{" "} + + ({db?.engine}) + +

+ +
+ +
+ {SECTIONS.map(({ kind, label, description, icon }) => ( + + ))} +
+ + {isMultiDb && hasAnyConfigured && ( + + )} +
+); diff --git a/src/features/onboarding/components/db-settings/db-grid.tsx b/src/features/onboarding/components/db-settings/db-grid.tsx new file mode 100644 index 00000000..a2a099ed --- /dev/null +++ b/src/features/onboarding/components/db-settings/db-grid.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Check, ChevronRight, Database } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import type { OnboardingDatabase } from "@/features/onboarding/types"; + +type DbGridProps = { + databaseIds: string[]; + getDb: (id: string) => OnboardingDatabase | undefined; + isDbConfigured: (id: string) => boolean; + onSelectDb: (id: string) => void; + onContinue: () => void; +}; + +export const DbGrid = ({ + databaseIds, + getDb, + isDbConfigured, + onSelectDb, + onContinue, +}: DbGridProps) => ( +
+
+

Configure databases

+

+ Optional — configure backup policies for each database. +

+
+
+ {databaseIds.map((dbId) => { + const db = getDb(dbId); + return ( + + ); + })} +
+ +
+); diff --git a/src/features/onboarding/components/db-settings/db-section.tsx b/src/features/onboarding/components/db-settings/db-section.tsx new file mode 100644 index 00000000..a56ea1f7 --- /dev/null +++ b/src/features/onboarding/components/db-settings/db-section.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { ArrowLeft } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { RetentionSection } from "@/features/onboarding/components/db-settings/retention-section"; +import { SchedulingSection } from "@/features/onboarding/components/db-settings/scheduling-section"; +import { NotificationsSection } from "@/features/onboarding/components/db-settings/notifications-section"; +import { StorageSection } from "@/features/onboarding/components/db-settings/storage-section"; +import type { + OnboardingChannel, + OnboardingDatabase, + OnboardingDbSettings, + SectionKind, +} from "@/features/onboarding/types"; +import type { useApplyDbSettings } from "@/features/onboarding/hooks/use-apply-db-settings"; + +const SECTION_LABELS: Record = { + retention: "Retention Policy", + scheduling: "Scheduling", + notifications: "Notifications", + storage: "Storage", +}; + +type DbSectionProps = { + dbId: string; + db: OnboardingDatabase | undefined; + section: SectionKind; + settings: OnboardingDbSettings; + applyMutation: ReturnType; + notifiers: OnboardingChannel[]; + storages: OnboardingChannel[]; + onBack: () => void; + onSaved: () => void; + updateDbSettings: ( + dbId: string, + patch: Partial, + ) => Promise; +}; + +export const DbSection = ({ + dbId, + db, + section, + settings, + applyMutation, + notifiers, + storages, + onBack, + onSaved, + updateDbSettings, +}: DbSectionProps) => ( +
+
+

+ {SECTION_LABELS[section]}{" "} + + — {db?.name ?? dbId} + +

+ +
+ + {section === "retention" && ( + { + await applyMutation.mutateAsync({ + databaseId: dbId, + section: "retention", + retention, + }); + await updateDbSettings(dbId, { retention }); + toast.success("Retention policy saved."); + onSaved(); + }} + /> + )} + + {section === "scheduling" && ( + { + await applyMutation.mutateAsync({ + databaseId: dbId, + section: "scheduling", + backupMethod, + backupCron, + }); + await updateDbSettings(dbId, { backupMethod, backupCron }); + toast.success("Schedule saved."); + onSaved(); + }} + /> + )} + + {section === "notifications" && ( + { + await applyMutation.mutateAsync({ + databaseId: dbId, + section: "notifications", + notificationPolicies, + }); + await updateDbSettings(dbId, { notificationPolicies }); + toast.success("Notification policies saved."); + onSaved(); + }} + /> + )} + + {section === "storage" && ( + { + await applyMutation.mutateAsync({ + databaseId: dbId, + section: "storage", + storagePolicies, + }); + await updateDbSettings(dbId, { storagePolicies }); + toast.success("Storage policies saved."); + onSaved(); + }} + /> + )} +
+); diff --git a/src/features/onboarding/components/db-settings/notifications-section.tsx b/src/features/onboarding/components/db-settings/notifications-section.tsx new file mode 100644 index 00000000..e7cc2805 --- /dev/null +++ b/src/features/onboarding/components/db-settings/notifications-section.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { ChannelPoliciesForm } from "@/features/database/components/channels-policy-form"; +import type { EventKind, OnboardingChannel, OnboardingNotificationPolicy } from "@/features/onboarding/types"; +import type { PolicyType } from "@/features/database/schemas/channels-policy.schema"; + +type NotificationsSectionProps = { + initial: OnboardingNotificationPolicy[]; + notifiers: OnboardingChannel[]; + onSave: (policies: OnboardingNotificationPolicy[]) => Promise; + isPending: boolean; +}; + +export const NotificationsSection = ({ initial, notifiers, onSave, isPending }: NotificationsSectionProps) => ( + + onSave( + policies.map((p) => ({ + channelId: p.channelId, + eventKinds: (p.eventKinds ?? []) as EventKind[], + enabled: p.enabled, + })), + ) + } + noChannelsMessage={ +

+ Go back and configure notifiers in the "Connect a notifier" step first. +

+ } + /> +); diff --git a/src/features/onboarding/components/db-settings/retention-section.tsx b/src/features/onboarding/components/db-settings/retention-section.tsx new file mode 100644 index 00000000..fee4a05f --- /dev/null +++ b/src/features/onboarding/components/db-settings/retention-section.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { BackupRetentionSettingsForm } from "@/features/database/components/retention-policy-form"; +import { DEFAULT_RETENTION } from "@/features/onboarding/constants/db-settings"; +import type { OnboardingDbSettings } from "@/features/onboarding/types"; + +type RetentionSectionProps = { + initial: OnboardingDbSettings["retention"]; + onSave: (value: NonNullable) => Promise; + isPending: boolean; +}; + +export const RetentionSection = ({ initial, onSave, isPending }: RetentionSectionProps) => ( + + onSave({ + type: values.type, + count: values.count ?? DEFAULT_RETENTION.count, + days: values.days ?? DEFAULT_RETENTION.days, + gfs: { + daily: values.gfs?.daily ?? DEFAULT_RETENTION.gfs.daily, + weekly: values.gfs?.weekly ?? DEFAULT_RETENTION.gfs.weekly, + monthly: values.gfs?.monthly ?? DEFAULT_RETENTION.gfs.monthly, + yearly: values.gfs?.yearly ?? DEFAULT_RETENTION.gfs.yearly, + }, + }) + } + /> +); diff --git a/src/features/onboarding/components/db-settings/scheduling-section.tsx b/src/features/onboarding/components/db-settings/scheduling-section.tsx new file mode 100644 index 00000000..2833774e --- /dev/null +++ b/src/features/onboarding/components/db-settings/scheduling-section.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + BackupScheduleSelector, + type BackupScheduleValue, +} from "@/features/onboarding/components/backup-schedule-selector"; +import { DEFAULT_SCHEDULE } from "@/features/onboarding/constants/db-settings"; +import type { OnboardingDbSettings } from "@/features/onboarding/types"; + +type SchedulingSectionProps = { + initial: Pick; + onSave: (method: "manual" | "automatic", cron?: string) => Promise; + isPending: boolean; +}; + +export const SchedulingSection = ({ + initial, + onSave, + isPending, +}: SchedulingSectionProps) => { + const [schedule, setSchedule] = useState({ + method: initial.backupMethod ?? DEFAULT_SCHEDULE.method, + cron: initial.backupCron ?? DEFAULT_SCHEDULE.cron, + }); + + return ( +
+ +
+ +
+
+ ); +}; diff --git a/src/features/onboarding/components/db-settings/storage-section.tsx b/src/features/onboarding/components/db-settings/storage-section.tsx new file mode 100644 index 00000000..7c9340c9 --- /dev/null +++ b/src/features/onboarding/components/db-settings/storage-section.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { ChannelPoliciesForm } from "@/features/database/components/channels-policy-form"; +import type { OnboardingChannel, OnboardingStoragePolicy } from "@/features/onboarding/types"; +import type { PolicyType } from "@/features/database/schemas/channels-policy.schema"; + +type StorageSectionProps = { + initial: OnboardingStoragePolicy[]; + storages: OnboardingChannel[]; + onSave: (policies: OnboardingStoragePolicy[]) => Promise; + isPending: boolean; +}; + +export const StorageSection = ({ initial, storages, onSave, isPending }: StorageSectionProps) => ( + + onSave( + policies.map((p) => ({ + channelId: p.channelId, + enabled: p.enabled, + })), + ) + } + noChannelsMessage={ +

+ Go back and configure storages in the "Connect an external storage" step first. +

+ } + /> +); diff --git a/src/features/onboarding/constants/db-settings.ts b/src/features/onboarding/constants/db-settings.ts new file mode 100644 index 00000000..bdafada1 --- /dev/null +++ b/src/features/onboarding/constants/db-settings.ts @@ -0,0 +1,15 @@ +import type { OnboardingDbSettings } from "@/features/onboarding/types"; +import type { BackupScheduleValue } from "@/features/onboarding/components/backup-schedule-selector"; + +export const DEFAULT_RETENTION: NonNullable = + { + type: "gfs", + count: 7, + days: 30, + gfs: { daily: 7, weekly: 4, monthly: 12, yearly: 3 }, + }; + +export const DEFAULT_SCHEDULE: BackupScheduleValue = { + method: "automatic", + cron: "0 0 * * *", +}; diff --git a/src/features/onboarding/constants/steps.ts b/src/features/onboarding/constants/steps.ts new file mode 100644 index 00000000..6dbc4972 --- /dev/null +++ b/src/features/onboarding/constants/steps.ts @@ -0,0 +1,33 @@ +export const STEP_IDS = { + LOGIN: "login", + ACCOUNT_INFO: "account-info", + SECURITY: "security", + ORG_CREATE: "org-create", + //INVITE_MEMBERS: "invite-members", + NOTIFIER: "notifier", + STORAGE: "storage", + DEFAULTS: "defaults", + AGENT_CREATE: "agent-create", + AGENT_KEY: "agent-key", + AGENT_WAITING: "agent-waiting", + PROJECT_CREATE: "project-create", + DB_SETTINGS: "db-settings", + FINISH: "finish", +} as const; + +export const STEP_ORDER: string[] = [ + STEP_IDS.LOGIN, + STEP_IDS.ACCOUNT_INFO, + STEP_IDS.SECURITY, + STEP_IDS.ORG_CREATE, + //STEP_IDS.INVITE_MEMBERS, + STEP_IDS.NOTIFIER, + STEP_IDS.STORAGE, + STEP_IDS.DEFAULTS, + STEP_IDS.AGENT_CREATE, + STEP_IDS.AGENT_KEY, + STEP_IDS.AGENT_WAITING, + STEP_IDS.PROJECT_CREATE, + STEP_IDS.DB_SETTINGS, + STEP_IDS.FINISH, +]; diff --git a/src/features/onboarding/hooks/use-add-notifier.ts b/src/features/onboarding/hooks/use-add-notifier.ts new file mode 100644 index 00000000..1748ab79 --- /dev/null +++ b/src/features/onboarding/hooks/use-add-notifier.ts @@ -0,0 +1,55 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { addNotificationChannelAction } from "@/features/channel/components/notifications/channel.action"; +import type { OnboardingChannel } from "@/features/onboarding/types"; + +type NotifierInput = { + provider: string; + name: string; + config: Record; + label: string; +}; + +export const useAddNotifier = () => { + const { state, updateContext } = useOnboarding(); + + return useMutation({ + mutationFn: async ({ provider, name, config, label }: NotifierInput) => { + const orgId = (state?.context.flowData.org as any)?.id as + | string + | undefined; + const result = await addNotificationChannelAction({ + organizationId: orgId, + data: { + provider: provider as any, + name, + config: config as any, + enabled: true, + }, + }); + const inner = result?.data; + if (!inner?.success || !inner.value) + throw new Error("Failed to save channel"); + + const channel: OnboardingChannel = { + id: inner.value.id, + provider, + label, + name, + config, + }; + const notifiers = [ + ...((state?.context.flowData.notifiers ?? []) as OnboardingChannel[]), + channel, + ]; + await updateContext({ + flowData: { ...state?.context.flowData, notifiers }, + }); + return channel; + }, + onError: (err: Error) => toast.error(err.message), + }); +}; diff --git a/src/features/onboarding/hooks/use-add-storage.ts b/src/features/onboarding/hooks/use-add-storage.ts new file mode 100644 index 00000000..406c1832 --- /dev/null +++ b/src/features/onboarding/hooks/use-add-storage.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { addStorageChannelAction } from "@/features/channel/components/storages/channel.action"; +import type { OnboardingChannel } from "@/features/onboarding/types"; + +type StorageInput = { + provider: string; + name: string; + config: Record; + label: string; +}; + +export const useAddStorage = () => { + const { state, updateContext } = useOnboarding(); + + return useMutation({ + mutationFn: async ({ provider, name, config, label }: StorageInput) => { + const orgId = (state?.context.flowData.org as any)?.id as + | string + | undefined; + const result = await addStorageChannelAction({ + organizationId: orgId, + data: { + provider: provider as any, + name, + config: config as any, + enabled: true, + }, + }); + const inner = result?.data; + if (!inner?.success || !inner.value) + throw new Error("Failed to save storage"); + + const channel: OnboardingChannel = { + id: inner.value.id, + provider, + label, + name, + config, + organizationId: orgId ?? null, + }; + const storages = [ + ...((state?.context.flowData.storages ?? []) as OnboardingChannel[]), + channel, + ]; + await updateContext({ + flowData: { ...state?.context.flowData, storages }, + }); + return channel; + }, + onError: (err: Error) => toast.error(err.message), + }); +}; diff --git a/src/features/onboarding/hooks/use-agent-status.ts b/src/features/onboarding/hooks/use-agent-status.ts new file mode 100644 index 00000000..4ddccef6 --- /dev/null +++ b/src/features/onboarding/hooks/use-agent-status.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { getAgentStatusAction } from "@/features/onboarding/actions/get-agent-status.action"; +import type { OnboardingAgent } from "@/features/onboarding/types"; + +export const useAgentStatus = () => { + const { state } = useOnboarding(); + const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[]; + const firstAgentId = agents[0]?.id; + + return useQuery({ + queryKey: ["onboarding-agent-status", firstAgentId], + queryFn: async () => { + if (!firstAgentId) return { connected: false }; + const result = await getAgentStatusAction({ agentId: firstAgentId }); + return result?.data ?? { connected: false }; + }, + refetchInterval: 3_000, + enabled: !!firstAgentId, + }); +}; diff --git a/src/features/onboarding/hooks/use-apply-db-settings.ts b/src/features/onboarding/hooks/use-apply-db-settings.ts new file mode 100644 index 00000000..d474dd9c --- /dev/null +++ b/src/features/onboarding/hooks/use-apply-db-settings.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { applyOnboardingDbSettingsAction } from "@/features/onboarding/actions/apply-db-settings.action"; + +export const useApplyDbSettings = () => + useMutation({ + mutationFn: (args: Parameters[0]) => + applyOnboardingDbSettingsAction(args), + onError: () => toast.error("Failed to save settings."), + }); diff --git a/src/features/onboarding/hooks/use-create-agent.ts b/src/features/onboarding/hooks/use-create-agent.ts new file mode 100644 index 00000000..4bcfc305 --- /dev/null +++ b/src/features/onboarding/hooks/use-create-agent.ts @@ -0,0 +1,46 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { createAgentAction } from "@/features/agents/actions/agents.action"; +import type { OnboardingAgent, OnboardingDefaultsData } from "@/features/onboarding/types"; +import { generateEdgeKey } from "@/utils/edge_key"; +import { getServerUrl } from "@/utils/get-server-url"; + +export const useCreateAgent = () => { + const { state, updateContext } = useOnboarding(); + + return useMutation({ + mutationFn: async (name: string) => { + const orgId = (state?.context.flowData.org as any)?.id as string | undefined; + if (!orgId) throw new Error("Missing org ID — cannot create agent"); + + const defaults = (state?.context.flowData.defaults ?? {}) as OnboardingDefaultsData; + const result = await createAgentAction({ + organizationId: orgId, + data: { name, description: "" }, + }); + if (!result?.data?.data) { + throw new Error(result?.serverError ?? `Failed to create agent "${name}"`); + } + + const edgeKey = await generateEdgeKey(getServerUrl(), result.data.data.id); + + const newAgent: OnboardingAgent = { + id: result.data.data.id, + name: result.data.data.name, + edgeKey, + notifierId: defaults.notifierId, + storageId: defaults.storageId, + }; + const agents = [ + ...((state?.context.flowData.agents ?? []) as OnboardingAgent[]), + newAgent, + ]; + await updateContext({ flowData: { ...state?.context.flowData, agents } }); + return newAgent; + }, + onError: (err: Error) => toast.error(err.message), + }); +}; diff --git a/src/features/onboarding/hooks/use-create-org.ts b/src/features/onboarding/hooks/use-create-org.ts new file mode 100644 index 00000000..846908e5 --- /dev/null +++ b/src/features/onboarding/hooks/use-create-org.ts @@ -0,0 +1,63 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { + createOrganizationAction, + updateOrganizationAction, +} from "@/features/organizations/actions/organization.action"; +import { slugify } from "@/utils/slugify"; + +export const useCreateOrg = () => { + const { state, updateContext, next } = useOnboarding(); + + return useMutation({ + mutationFn: async (name: string) => { + const trimmed = name.trim(); + if (!trimmed) throw new Error("Organisation name is required"); + + const existingOrg = state?.context.flowData.org as + | { id: string; name: string } + | undefined; + + if (existingOrg) { + const result = await updateOrganizationAction({ + organizationId: existingOrg.id, + data: { name: trimmed, slug: slugify(trimmed), users: [] }, + }); + const updateData = result?.data; + if (!updateData?.success) { + throw new Error( + updateData?.actionError?.message ?? "Failed to update organisation", + ); + } + await updateContext({ + flowData: { + ...state?.context.flowData, + org: { id: existingOrg.id, name: trimmed }, + }, + }); + } else { + const result = await createOrganizationAction({ name: trimmed }); + const createData = result?.data; + if (!createData?.success) { + throw new Error( + createData?.actionError?.message ?? "Failed to create organisation", + ); + } + const org = createData.value; + if (!org) throw new Error("Failed to create organisation"); + await updateContext({ + flowData: { + ...state?.context.flowData, + org: { id: org.id, name: org.name }, + }, + }); + } + + await next(); + }, + onError: (err: Error) => toast.error(err.message), + }); +}; diff --git a/src/features/onboarding/hooks/use-create-project.ts b/src/features/onboarding/hooks/use-create-project.ts new file mode 100644 index 00000000..53e5109f --- /dev/null +++ b/src/features/onboarding/hooks/use-create-project.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { + createProjectAction, + updateProjectAction, +} from "@/features/projects/actions/projects.action"; +import type { OnboardingProjectData } from "@/features/onboarding/types"; + +type ProjectInput = { name: string; databaseIds: string[] }; + +export const useCreateProject = () => { + const { state, updateContext } = useOnboarding(); + + return useMutation({ + mutationFn: async ({ name, databaseIds }: ProjectInput) => { + const trimmed = name.trim(); + if (!trimmed) throw new Error("Project name is required"); + const orgId = (state?.context.flowData.org as any)?.id as string | undefined; + if (!orgId) throw new Error("No organisation ID found"); + const existingProject = state?.context.flowData.project as OnboardingProjectData | undefined; + + if (existingProject?.id) { + const result = await updateProjectAction({ + data: { name: trimmed, databases: databaseIds }, + organizationId: orgId, + projectId: existingProject.id, + }); + const updateData = result?.data; + if (!updateData?.success) { + throw new Error(updateData?.actionError?.message ?? "Failed to update project"); + } + await updateContext({ + flowData: { + ...state?.context.flowData, + project: { id: existingProject.id, name: trimmed, databaseIds }, + }, + }); + } else { + const result = await createProjectAction({ + data: { name: trimmed, databases: databaseIds }, + organizationId: orgId, + }); + const createData = result?.data; + if (!createData?.success) { + throw new Error(createData?.actionError?.message ?? "Failed to create project"); + } + const project = createData.value; + if (!project) throw new Error("Failed to create project"); + await updateContext({ + flowData: { + ...state?.context.flowData, + project: { id: project.id, name: project.name, databaseIds }, + }, + }); + } + }, + onError: (err: Error) => toast.error(err.message), + }); +}; diff --git a/src/features/onboarding/hooks/use-delete-agent.ts b/src/features/onboarding/hooks/use-delete-agent.ts new file mode 100644 index 00000000..cc363d36 --- /dev/null +++ b/src/features/onboarding/hooks/use-delete-agent.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { deleteAgentAction } from "@/features/agents/actions/agent-delete.action"; +import type { OnboardingAgent } from "@/features/onboarding/types"; + +export const useDeleteAgent = () => { + const { state, updateContext } = useOnboarding(); + + return useMutation({ + mutationFn: async (agentId: string) => { + const orgId = (state?.context.flowData.org as any)?.id as string | undefined; + const result = await deleteAgentAction({ agentId, organizationId: orgId }); + if (result?.data?.success === false) throw new Error("Failed to delete agent"); + + const agents = ( + (state?.context.flowData.agents ?? []) as OnboardingAgent[] + ).filter((a) => a.id !== agentId); + await updateContext({ flowData: { ...state?.context.flowData, agents } }); + }, + onError: (err: Error) => toast.error(err.message), + }); +}; diff --git a/src/features/onboarding/hooks/use-mark-onboarding-done.ts b/src/features/onboarding/hooks/use-mark-onboarding-done.ts new file mode 100644 index 00000000..bf3cf3d6 --- /dev/null +++ b/src/features/onboarding/hooks/use-mark-onboarding-done.ts @@ -0,0 +1,14 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { markOnboardingDoneAction } from "@/features/onboarding/actions/onboarding-mark-done.action"; + +export const useMarkOnboardingDone = () => { + return useMutation({ + mutationFn: async () => { + const result = await markOnboardingDoneAction({}); + if (!result?.data) throw new Error("Failed to mark onboarding done"); + return result.data; + }, + }); +}; diff --git a/src/features/onboarding/hooks/use-remove-notifier.ts b/src/features/onboarding/hooks/use-remove-notifier.ts new file mode 100644 index 00000000..fb8c974f --- /dev/null +++ b/src/features/onboarding/hooks/use-remove-notifier.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { removeNotificationChannelAction } from "@/features/channel/components/notifications/channel.action"; +import type { OnboardingChannel } from "@/features/onboarding/types"; + +export const useRemoveNotifier = () => { + const { state, updateContext } = useOnboarding(); + + return useMutation({ + mutationFn: async (id: string) => { + const orgId = (state?.context.flowData.org as any)?.id as + | string + | undefined; + const result = await removeNotificationChannelAction({ + organizationId: orgId, + notificationChannelId: id, + }); + if (result?.data?.success === false) + throw new Error("Failed to remove channel"); + const notifiers = ( + (state?.context.flowData.notifiers ?? []) as OnboardingChannel[] + ).filter((c) => c.id !== id); + await updateContext({ + flowData: { ...state?.context.flowData, notifiers }, + }); + }, + onError: (err: Error) => toast.error(err.message), + }); +}; diff --git a/src/features/onboarding/hooks/use-remove-storage.ts b/src/features/onboarding/hooks/use-remove-storage.ts new file mode 100644 index 00000000..6b377037 --- /dev/null +++ b/src/features/onboarding/hooks/use-remove-storage.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { removeStorageChannelAction } from "@/features/channel/components/storages/channel.action"; +import type { OnboardingChannel } from "@/features/onboarding/types"; + +export const useRemoveStorage = () => { + const { state, updateContext } = useOnboarding(); + + return useMutation({ + mutationFn: async (id: string) => { + const orgId = (state?.context.flowData.org as any)?.id as + | string + | undefined; + const result = await removeStorageChannelAction({ + organizationId: orgId, + id, + }); + if (result?.data?.success === false) + throw new Error("Failed to remove storage"); + const storages = ( + (state?.context.flowData.storages ?? []) as OnboardingChannel[] + ).filter((c) => c.id !== id); + await updateContext({ + flowData: { ...state?.context.flowData, storages }, + }); + }, + onError: (err: Error) => toast.error(err.message), + }); +}; diff --git a/src/features/onboarding/hooks/use-update-account.ts b/src/features/onboarding/hooks/use-update-account.ts new file mode 100644 index 00000000..d06164bd --- /dev/null +++ b/src/features/onboarding/hooks/use-update-account.ts @@ -0,0 +1,115 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { z } from "zod"; +import { authClient, signUp, passkey, signIn } from "@/lib/auth/auth-client"; +import { updateAccountAction } from "@/features/onboarding/actions/update-account.action"; +import { generatePasskeyContextAction } from "@/features/onboarding/actions/generate-passkey-context.action"; +import { WithPasswordSchema } from "@/features/onboarding/schemas/account.schema"; + +type AccountInput = z.infer & { + method?: "passkey" | "password"; +}; + +export const useUpdateAccount = (refetchSession: () => Promise) => { + const { state, updateContext, next } = useOnboarding(); + + return useMutation({ + mutationFn: async (values: AccountInput) => { + const existingAccount = state?.context.flowData.account; + const isUpdateMode = !!existingAccount; + const selectedMethod = values.method ?? "password"; + + if (isUpdateMode) { + const result = await updateAccountAction({ + firstName: values.firstName, + lastName: values.lastName, + }); + if (!result?.data) throw new Error("Failed to update account"); + await updateContext({ + flowData: { + ...state?.context.flowData, + account: { + firstName: values.firstName, + lastName: values.lastName, + email: values.email, + }, + }, + }); + await next(); + return; + } + + if (selectedMethod === "passkey") { + const name = `${values.firstName} ${values.lastName}`; + const ctxResult = await generatePasskeyContextAction({ + name, + email: values.email, + }); + const context = ctxResult?.data; + if (!context) throw new Error("Failed to generate passkey context"); + const result = await passkey.addPasskey({ + name: values.email, + context, + }); + if (result?.error) { + throw new Error( + result.error.message ?? "Passkey registration failed", + ); + } + await refetchSession(); + const { data: freshSession } = await authClient.getSession(); + if (!freshSession?.user) { + const signInResult = await (signIn as any).passkey(); + if (signInResult?.error) { + throw new Error( + "Registration succeeded but sign-in failed. Please reload and sign in.", + ); + } + } + await updateContext({ + flowData: { + ...state?.context.flowData, + account: { + firstName: values.firstName, + lastName: values.lastName, + email: values.email, + }, + security: { method: "passkey" }, + }, + }); + await next(); + return; + } + + await signUp.email( + { + name: `${values.firstName} ${values.lastName}`, + email: values.email, + password: values.password ?? "", + } as any, + { + onSuccess: async () => { + await updateContext({ + flowData: { + ...state?.context.flowData, + account: { + firstName: values.firstName, + lastName: values.lastName, + email: values.email, + }, + }, + }); + await next(); + }, + onError: (error) => { + toast.error(error.error.message); + }, + }, + ); + }, + onError: (err: Error) => toast.error(err.message), + }); +}; diff --git a/src/features/onboarding/is-onboarding-done.ts b/src/features/onboarding/is-onboarding-done.ts new file mode 100644 index 00000000..ac97c184 --- /dev/null +++ b/src/features/onboarding/is-onboarding-done.ts @@ -0,0 +1,8 @@ +import "server-only"; + +import { db } from "@/db"; + +export async function isOnboardingDone(): Promise { + const settings = await db.query.setting.findFirst(); + return settings?.onboarding ?? false; +} diff --git a/src/features/onboarding/onboarding-checklist.tsx b/src/features/onboarding/onboarding-checklist.tsx new file mode 100644 index 00000000..6a0f6929 --- /dev/null +++ b/src/features/onboarding/onboarding-checklist.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useOnboarding } from "@onboardjs/react"; +import { CheckCircle2, Circle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const CHECKLIST_STEPS = [ + { id: "login", label: "Sign in" }, + { id: "account-info", label: "Your account" }, + { id: "security", label: "Security" }, + { id: "org-create", label: "Organisation" }, + // { id: "invite-members", label: "Team members" }, + { id: "notifier", label: "Notifications" }, + { id: "storage", label: "Storage" }, + { id: "defaults", label: "Defaults" }, + { id: "agent-create", label: "Agent setup" }, + { id: "agent-key", label: "Agent key" }, + { id: "agent-waiting", label: "Agent connection" }, + { id: "project-create", label: "Project" }, + { id: "db-settings", label: "Database settings" }, + { id: "finish", label: "Done" }, +] as const; + +export const OnboardingChecklist = () => { + const { state } = useOnboarding(); + if (!state) return null; + + const currentId = state.currentStep?.id ?? ""; + const currentIndex = CHECKLIST_STEPS.findIndex((s) => s.id === currentId); + + return ( +
+
+ {CHECKLIST_STEPS.map((step, i) => { + const isCompleted = i < currentIndex; + const isCurrent = i === currentIndex; + const isLast = i === CHECKLIST_STEPS.length - 1; + + return ( +
+
+ {isCompleted ? ( + + ) : ( + + )} + {!isLast && ( +
+ )} +
+ +

+ {step.label} +

+
+ ); + })} +
+
+ ); +}; diff --git a/src/features/onboarding/onboarding-shell.tsx b/src/features/onboarding/onboarding-shell.tsx new file mode 100644 index 00000000..4a73b20f --- /dev/null +++ b/src/features/onboarding/onboarding-shell.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { Button } from "@/components/ui/button"; +import { OnboardingStepper } from "@/features/onboarding/onboarding-stepper"; +import { OnboardingChecklist } from "@/features/onboarding/onboarding-checklist"; +import { Heart } from "lucide-react"; +import { AuthLogoSection } from "../auth/components/auth-logo-section"; +import { STEP_ORDER } from "@/features/onboarding/constants/steps"; + +export const OnboardingShell = () => { + const { state, previous, skip, next, goToStep, renderStep } = useOnboarding(); + + const currentStepId = String(state?.currentStep?.id ?? ""); + const currentIndex = STEP_ORDER.indexOf(currentStepId); + + const [latestIndex, setLatestIndex] = useState(currentIndex); + + useEffect(() => { + setLatestIndex((prev) => Math.max(prev, currentIndex)); + }, [currentIndex]); + + if (!state) return null; + + const showSplit = + currentStepId !== "agent-waiting" && currentStepId !== "finish"; + + const isGoingBack = currentIndex < latestIndex; + + const BLOCKED_STEPS = ["security"]; + const prevStepId = STEP_ORDER[currentIndex - 1] ?? ""; + const canGoBack = + !BLOCKED_STEPS.includes(currentStepId) && + !BLOCKED_STEPS.includes(prevStepId) && + currentIndex > 0; + + return ( +
+ +
+
+ +
{renderStep()}
+
+ {currentIndex > 0 && ( + + )} +
+ {state.isSkippable && ( + + )} + {isGoingBack && ( + + )} +
+
+
+ {showSplit && ( +
+ +
+ )} +
+ +
+

+ Made with{" "} + {" "} + by Portabase +

+
+
+ ); +}; diff --git a/src/features/onboarding/onboarding-state.ts b/src/features/onboarding/onboarding-state.ts new file mode 100644 index 00000000..152c8f7e --- /dev/null +++ b/src/features/onboarding/onboarding-state.ts @@ -0,0 +1,233 @@ +import "server-only"; + +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { organization } from "@/db/schema/03_organization"; +import { member } from "@/db/schema/04_member"; +import { currentUser } from "@/lib/auth/current-user"; +import { getSettings } from "@/db/services/setting"; +import { hasUsers } from "@/db/services/user"; +import { + getUserOrganization, + getUserOwnOrganization, +} from "@/db/services/organization"; +import { getOrganizationProject } from "@/db/services/project"; +import { getOrganizationAgents } from "@/db/services/agent"; +import { getDatabasesSettings } from "@/db/services/database"; +import { getOrganizationChannels } from "@/db/services/notification-channel"; +import { getOrganizationStorageChannels } from "@/db/services/storage-channel"; +import type { AgentWith } from "@/db/schema/08_agent"; +import { env } from "@/env.mjs"; +import type { + OnboardingDatabase, + OnboardingFlowData, + OnboardingMeta, +} from "@/features/onboarding/types"; +import { getOAuthProviders } from "@/lib/auth/oauth"; +import { getOidcProviders } from "@/lib/auth/oidc"; +import { generateEdgeKey } from "@/utils/edge_key"; +import { getServerUrl } from "@/utils/get-server-url"; + +export type ResolvedOnboardingState = + | { redirect: "/dashboard/home" } + | { stepId: string; flowData: Partial }; + +export async function resolveOnboardingState(): Promise { + const settings = await getSettings(); + if (settings?.onboarding) { + return { redirect: "/dashboard/home" }; + } + + const meta: OnboardingMeta = { + passkeyEnabled: env.AUTH_PASSKEY_ENABLED === "true", + emailPasswordEnabled: env.AUTH_EMAIL_PASSWORD_ENABLED === "true", + hasExistingUsers: false, + ssoProviders: [], + oauthProviders: [], + defaultUserMode: !!(env.AUTH_DEFAULT_USER && env.AUTH_DEFAULT_PASSWORD), + resumeStepId: "login", + }; + + meta.hasExistingUsers = await hasUsers(); + + for (const p of getOidcProviders()) { + meta.ssoProviders.push({ + id: p.id, + label: p.title, + icon: p.icon, + description: p.description || undefined, + }); + } + + for (const p of getOAuthProviders()) { + meta.oauthProviders.push({ id: p.id, label: p.title, icon: p.icon }); + } + + const user = await currentUser(); + if (!user) { + return { stepId: "login", flowData: { meta } }; + } + + const inDefaultOrg = await getUserOrganization(user.id); + if (!inDefaultOrg) { + const defaultOrg = await db.query.organization.findFirst({ + where: eq(organization.slug, "default"), + }); + if (defaultOrg) { + await db.insert(member).values({ + userId: user.id, + organizationId: defaultOrg.id, + role: "owner", + }); + } + } + + const org = await getUserOwnOrganization(user.id); + if (!org) { + meta.resumeStepId = "org-create"; + return { stepId: "org-create", flowData: { meta } }; + } + + const orgData = { id: org.id, name: org.name }; + + const [notifierChannels, storageChannels, agents, project] = + await Promise.all([ + getOrganizationChannels(org.id), + getOrganizationStorageChannels(org.id), + getOrganizationAgents(org.id), + getOrganizationProject(org.id), + ]); + + const notifiers = notifierChannels.map((n) => ({ + id: n.id, + provider: n.provider, + label: n.provider, + name: n.name, + config: (n.config as Record) ?? {}, + organizationId: n.organizationId ?? null, + })); + + const storages = storageChannels + .filter((s) => s.provider !== "local") + .map((s) => ({ + id: s.id, + provider: s.provider, + label: s.provider, + name: s.name, + config: (s.config as Record) ?? {}, + organizationId: s.organizationId ?? null, + })); + + const defaults = { + notifierId: settings?.defaultNotificationChannelId ?? undefined, + storageId: settings?.defaultStorageChannelId ?? undefined, + }; + + const agentData = await Promise.all( + (agents ?? []).map(async (a) => ({ + id: a.id, + name: a.name, + edgeKey: await generateEdgeKey(getServerUrl(), a.id), + connected: !!a.lastContact, + })), + ); + + const databases = (agents as AgentWith[]).flatMap((a) => + (a.databases ?? []).map((d) => ({ + id: d.id, + name: d.name, + engine: (d.dbms === "postgresql" + ? "postgres" + : d.dbms) as OnboardingDatabase["engine"], + })), + ); + + const dbSettings = await getDatabasesSettings(databases.map((d) => d.id)); + + const fullData: Partial = { + meta, + org: orgData, + notifiers, + storages, + defaults, + agents: agentData, + databases, + dbSettings, + ...(project + ? { + project: { + id: project.id, + name: project.name, + description: "", + databaseIds: + (project as any).databases?.map((db: any) => db.id) ?? [], + }, + } + : {}), + }; + + const hasAgents = agents && agents.length > 0; + + const defaultsConfigured = + !!settings?.defaultNotificationChannelId && + !!settings?.defaultStorageChannelId; + + if (project) { + if (!hasAgents) { + if (notifiers.length === 0) { + meta.resumeStepId = "notifier"; + return { stepId: "notifier", flowData: fullData }; + } + if (storages.length === 0) { + meta.resumeStepId = "storage"; + return { stepId: "storage", flowData: fullData }; + } + if (defaultsConfigured) { + meta.resumeStepId = "agent-create"; + return { stepId: "agent-create", flowData: fullData }; + } + meta.resumeStepId = "defaults"; + return { stepId: "defaults", flowData: fullData }; + } + + const agentHasPinged = !!agents[0]?.lastContact; + if (!agentHasPinged) { + meta.resumeStepId = "agent-key"; + return { stepId: "agent-key", flowData: fullData }; + } + + const projectDatabaseIds: string[] = + (project as any).databases?.map((db: any) => db.id) ?? []; + if (projectDatabaseIds.length > 0) { + meta.resumeStepId = "db-settings"; + return { stepId: "db-settings", flowData: fullData }; + } + meta.resumeStepId = "project-create"; + return { stepId: "project-create", flowData: fullData }; + } + + if (hasAgents) { + const agentHasPinged = !!agents[0]?.lastContact; + const stepId = agentHasPinged ? "project-create" : "agent-key"; + meta.resumeStepId = stepId; + return { stepId, flowData: fullData }; + } + + if (notifiers.length === 0) { + meta.resumeStepId = "notifier"; + return { stepId: "notifier", flowData: fullData }; + } + + if (storages.length === 0) { + meta.resumeStepId = "storage"; + return { stepId: "storage", flowData: fullData }; + } + + if (defaultsConfigured) { + meta.resumeStepId = "agent-create"; + return { stepId: "agent-create", flowData: fullData }; + } + + meta.resumeStepId = "defaults"; + return { stepId: "defaults", flowData: fullData }; +} diff --git a/src/features/onboarding/onboarding-stepper.tsx b/src/features/onboarding/onboarding-stepper.tsx new file mode 100644 index 00000000..bdaaa603 --- /dev/null +++ b/src/features/onboarding/onboarding-stepper.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useOnboarding } from "@onboardjs/react"; +import { Progress } from "@/components/ui/progress"; +import { STEP_ORDER } from "@/features/onboarding/constants/steps"; +import { useIsMobile } from "@/hooks/use-mobile"; + +export const OnboardingStepper = () => { + const { state } = useOnboarding(); + const mobile = useIsMobile(); + if (!state) return null; + + const currentId = String(state.currentStep?.id ?? ""); + const currentIndex = Math.max(0, STEP_ORDER.indexOf(currentId)); + const totalSteps = STEP_ORDER.length; + const stepNumber = currentIndex + 1; + const progress = Math.round((currentIndex / (totalSteps - 1)) * 100); + + return ( +
+
+ {mobile && ( + + Step {stepNumber} of {totalSteps} + + )} +
+ +
+ ); +}; diff --git a/src/features/onboarding/onboarding-steps.tsx b/src/features/onboarding/onboarding-steps.tsx new file mode 100644 index 00000000..85fdf4da --- /dev/null +++ b/src/features/onboarding/onboarding-steps.tsx @@ -0,0 +1,156 @@ +import { OnboardingStep } from "@onboardjs/react"; +import { StepLogin } from "@/features/onboarding/steps/step-login"; +import { StepAccountInfo } from "@/features/onboarding/steps/step-account-info"; +import { StepSecurity } from "@/features/onboarding/steps/step-security"; +import { StepOrgCreate } from "@/features/onboarding/steps/step-org-create"; +import { StepInviteMembers } from "@/features/onboarding/steps/step-invite-members"; +import { StepNotifier } from "@/features/onboarding/steps/step-notifier"; +import { StepStorage } from "@/features/onboarding/steps/step-storage"; +import { StepDefaults } from "@/features/onboarding/steps/step-defaults"; +import { StepAgentCreate } from "@/features/onboarding/steps/step-agent-create"; +import { StepAgentKey } from "@/features/onboarding/steps/step-agent-key"; +import { StepAgentWaiting } from "@/features/onboarding/steps/step-agent-waiting"; +import { StepProjectCreate } from "@/features/onboarding/steps/step-project-create"; +import { StepDbSettings } from "@/features/onboarding/steps/step-db-settings"; +import { StepFinish } from "@/features/onboarding/steps/step-finish"; + +export const onboardingSteps: OnboardingStep[] = [ + { + id: "login", + component: StepLogin, + isSkippable: false, + nextStep: (ctx: any) => + ctx.flowData?.meta?.hasExistingUsers ? "org-create" : "account-info", + }, + { + id: "account-info", + component: StepAccountInfo, + isSkippable: false, + nextStep: (ctx: any) => + ctx.flowData?.meta?.passkeyEnabled ? "org-create" : "security", + }, + { + id: "security", + component: StepSecurity, + isSkippable: true, + skipToStep: "org-create", + nextStep: "org-create", + }, + { + id: "org-create", + component: StepOrgCreate, + isSkippable: false, + //nextStep: "invite-members", + nextStep: "notifier", + }, + // { + // id: "invite-members", + // component: StepInviteMembers, + // isSkippable: true, + // skipToStep: "notifier", + // nextStep: "notifier", + // }, + { + id: "notifier", + component: StepNotifier, + isSkippable: true, + skipToStep: "storage", + nextStep: "storage", + }, + { + id: "storage", + component: StepStorage, + isSkippable: true, + skipToStep: (ctx: any) => { + const notifiers = (ctx.flowData?.notifiers as unknown[]) || []; + const storages = (ctx.flowData?.storages as unknown[]) || []; + return notifiers.length === 0 && storages.length === 0 + ? "agent-create" + : "defaults"; + }, + nextStep: (ctx: any) => { + const notifiers = (ctx.flowData?.notifiers as unknown[]) || []; + const storages = (ctx.flowData?.storages as unknown[]) || []; + return notifiers.length === 0 && storages.length === 0 + ? "agent-create" + : "defaults"; + }, + }, + { + id: "defaults", + component: StepDefaults, + isSkippable: true, + skipToStep: "agent-create", + nextStep: "agent-create", + }, + { + id: "agent-create", + component: StepAgentCreate, + isSkippable: true, + skipToStep: (ctx: any) => { + const hasProject = !!ctx.flowData?.project; + return hasProject ? "project-create" : "finish"; + }, + nextStep: (ctx: any) => { + const agents = ctx.flowData?.agents as unknown[] | undefined; + const hasProject = !!ctx.flowData?.project; + if (agents && agents.length > 0) return "agent-key"; + return hasProject ? "project-create" : "finish"; + }, + }, + { + id: "agent-key", + component: StepAgentKey, + isSkippable: false, + nextStep: (ctx: any) => { + const agents = (ctx.flowData?.agents as any[]) || []; + const isConnected = agents.some((a) => a.connected); + return isConnected ? "project-create" : "agent-waiting"; + }, + }, + { + id: "agent-waiting", + component: StepAgentWaiting, + isSkippable: true, + skipToStep: "project-create", + nextStep: "project-create", + }, + { + id: "project-create", + component: StepProjectCreate, + isSkippable: true, + skipToStep: (ctx: any) => { + const agents = (ctx.flowData?.agents as any[]) || []; + if (agents.length === 0) return "finish"; + const isAgentConnected = agents.some((a) => a.connected); + const databaseIds = + (ctx.flowData?.project?.databaseIds as string[]) || []; + return !isAgentConnected || databaseIds.length === 0 + ? "finish" + : "db-settings"; + }, + nextStep: (ctx: any) => { + const agents = (ctx.flowData?.agents as any[]) || []; + if (agents.length === 0) return "finish"; + const isAgentConnected = agents.some((a) => a.connected); + const databaseIds = + (ctx.flowData?.project?.databaseIds as string[]) || []; + return !isAgentConnected || databaseIds.length === 0 + ? "finish" + : "db-settings"; + }, + }, + { + id: "db-settings", + component: StepDbSettings, + isSkippable: true, + skipToStep: "finish", + nextStep: "finish", + }, + { + id: "finish", + component: StepFinish, + isSkippable: false, + nextStep: null, + }, +]; diff --git a/src/features/onboarding/schemas/account.schema.ts b/src/features/onboarding/schemas/account.schema.ts new file mode 100644 index 00000000..651fbf63 --- /dev/null +++ b/src/features/onboarding/schemas/account.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const BaseSchema = z.object({ + firstName: z.string().min(1, "First name required"), + lastName: z.string().min(1, "Last name required"), + email: z.string().email("Invalid email"), +}); + +export const WithPasswordSchema = BaseSchema.extend({ + password: z.string().min(8, "Min. 8 characters"), +}); diff --git a/src/features/onboarding/schemas/db-settings.schema.ts b/src/features/onboarding/schemas/db-settings.schema.ts new file mode 100644 index 00000000..f631d956 --- /dev/null +++ b/src/features/onboarding/schemas/db-settings.schema.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +export const RetentionSchema = z.object({ + type: z.enum(["count", "days", "gfs"]).optional(), + count: z.number().min(1).max(100), + days: z.number().min(1).max(3650), + gfs: z.object({ + daily: z.number().min(1).max(31), + weekly: z.number().min(0).max(52), + monthly: z.number().min(0).max(120), + yearly: z.number().min(0).max(50), + }), +}); + +export const EventKindSchema = z.enum([ + "error_backup", + "error_restore", + "success_restore", + "success_backup", + "weekly_report", + "error_health_agent", + "error_health_database", +] as const); + +export const NotifPolicySchema = z.object({ + channelId: z.string().min(1), + eventKinds: z.array(EventKindSchema), + enabled: z.boolean(), +}); + +export const StoragePolicyInputSchema = z.object({ + channelId: z.string().min(1), + enabled: z.boolean(), +}); + +export const ApplyDbSettingsSchema = z.object({ + databaseId: z.string().min(1), + section: z.enum(["retention", "scheduling", "notifications", "storage", "all"]), + retention: RetentionSchema.optional(), + backupMethod: z.enum(["manual", "automatic"]).optional(), + backupCron: z.string().optional(), + notificationPolicies: z.array(NotifPolicySchema).optional(), + storagePolicies: z.array(StoragePolicyInputSchema).optional(), +}); diff --git a/src/features/onboarding/steps/step-account-info.tsx b/src/features/onboarding/steps/step-account-info.tsx new file mode 100644 index 00000000..8c8d4105 --- /dev/null +++ b/src/features/onboarding/steps/step-account-info.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useEffect } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { useSession } from "@/lib/auth/auth-client"; +import { z } from "zod"; +import { + useZodForm, + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; +import { Button } from "@/components/ui/button"; +import { + BaseSchema, + WithPasswordSchema, +} from "@/features/onboarding/schemas/account.schema"; +import { useUpdateAccount } from "@/features/onboarding/hooks/use-update-account"; +import type { OnboardingMeta } from "@/features/onboarding/types"; + +export const StepAccountInfo = () => { + const { next, state } = useOnboarding(); + const { data: session, refetch: refetchSession } = useSession(); + const meta = state?.context.flowData.meta as OnboardingMeta | undefined; + const existingAccount = state?.context.flowData.account; + const isUpdateMode = !!existingAccount; + const passkeyEnabled = meta?.passkeyEnabled ?? false; + const emailPasswordEnabled = meta?.emailPasswordEnabled ?? false; + + useEffect(() => { + if (session?.user && !isUpdateMode) { + next(); + } + }, [session?.user?.id, next, session?.user, isUpdateMode]); + + const schema = isUpdateMode + ? BaseSchema + : z.object({ + ...BaseSchema.shape, + password: z.string().optional(), + }); + + const form = useZodForm({ schema }) as unknown as ReturnType< + typeof useZodForm + >; + + const mutation = useUpdateAccount(refetchSession); + + const onSubmitPassword = async () => { + const valid = await form.trigger(); + if (!valid) return; + const values = form.getValues(); + if (!values.password || values.password.length < 8) { + form.setError("password", { message: "Min. 8 characters" }); + return; + } + mutation.mutateAsync({ ...(values as any), method: "password" }); + }; + + const onSubmitPasskey = async () => { + const valid = await form.trigger(["firstName", "lastName", "email"] as any); + if (!valid) return; + mutation.mutateAsync({ ...(form.getValues() as any), method: "passkey" }); + }; + + return ( +
+
+

+ {isUpdateMode ? "Update your account" : "Create your account"} +

+

+ This step can't be skipped. +

+
+
{ + if (isUpdateMode) { + mutation.mutateAsync({ ...(values as any) }); + } else if (!emailPasswordEnabled) { + mutation.mutateAsync({ ...(values as any), method: "passkey" }); + } else { + if (!values.password || (values.password as string).length < 8) { + form.setError("password", { message: "Min. 8 characters" }); + return; + } + mutation.mutateAsync({ ...(values as any), method: "password" }); + } + }} + > +
+ ( + + First name + + + + + + )} + /> + ( + + Last name + + + + + + )} + /> +
+ ( + + Email + + + + + + )} + /> + {!isUpdateMode && emailPasswordEnabled && ( + ( + + Password + + + + + + )} + /> + )} +
+ {isUpdateMode ? ( + + ) : ( + <> + {emailPasswordEnabled && ( + + )} + {passkeyEnabled && ( + + )} + + )} +
+ +
+ ); +}; diff --git a/src/features/onboarding/steps/step-agent-create.tsx b/src/features/onboarding/steps/step-agent-create.tsx new file mode 100644 index 00000000..aa404c2f --- /dev/null +++ b/src/features/onboarding/steps/step-agent-create.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useCreateAgent } from "@/features/onboarding/hooks/use-create-agent"; +import { useDeleteAgent } from "@/features/onboarding/hooks/use-delete-agent"; +import type { OnboardingAgent } from "@/features/onboarding/types"; + +export const StepAgentCreate = () => { + const { next, state } = useOnboarding(); + const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[]; + + const [name, setName] = useState(""); + const createAgent = useCreateAgent(); + const deleteAgent = useDeleteAgent(); + + const onAdd = () => { + if (!name.trim()) return; + createAgent.mutate(name.trim(), { onSuccess: () => setName("") }); + }; + + return ( +
+
+

Create an agent

+

+ Optional — agents will use your default notifier and storage. +

+
+ + {agents.length > 0 && ( +
+ {agents.map((agent) => ( +
+ {agent.name} + +
+ ))} +
+ )} + +
+ setName(e.target.value)} + placeholder="agent-prod" + onKeyDown={(e) => e.key === "Enter" && onAdd()} + disabled={createAgent.isPending} + /> + +
+ + +
+ ); +}; diff --git a/src/features/onboarding/steps/step-agent-key.tsx b/src/features/onboarding/steps/step-agent-key.tsx new file mode 100644 index 00000000..ca9f2632 --- /dev/null +++ b/src/features/onboarding/steps/step-agent-key.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { Server } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { AgentCardKey } from "@/features/agents/components/agent-card-key"; +import type { OnboardingAgent } from "@/features/onboarding/types"; +import { cn } from "@/lib/utils"; + +export const StepAgentKey = () => { + const { next, state } = useOnboarding(); + const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[]; + const [selectedId, setSelectedId] = useState(agents[0]?.id ?? ""); + + const selected = agents.find((a) => a.id === selectedId) ?? agents[0]; + + return ( +
+
+

Connect your agent

+

+ Run the command below on your server. The next step waits for the + agent to connect. +

+
+ + {agents.length > 1 && ( +
+

+ Agents +

+
+ {agents.map((agent) => ( + + ))} +
+
+ )} + + {selected?.edgeKey && ( + + )} + + +
+ ); +}; diff --git a/src/features/onboarding/steps/step-agent-waiting.tsx b/src/features/onboarding/steps/step-agent-waiting.tsx new file mode 100644 index 00000000..9a0e6e8e --- /dev/null +++ b/src/features/onboarding/steps/step-agent-waiting.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useOnboarding } from "@onboardjs/react"; +import { Loader2 } from "lucide-react"; +import { useAgentStatus } from "@/features/onboarding/hooks/use-agent-status"; +import type { OnboardingAgent } from "@/features/onboarding/types"; + +export const StepAgentWaiting = () => { + const { state } = useOnboarding(); + const router = useRouter(); + const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[]; + const { data, isLoading } = useAgentStatus(); + + useEffect(() => { + if (data?.connected) { + router.refresh(); + } + }, [data?.connected, router]); + + if (isLoading || data?.connected) return null; + + return ( +
+ +

Waiting for your agent

+

+ Checking connectivity every 3 seconds… +

+ {agents.length > 0 && ( +

Agent: {agents[0].name}

+ )} +
+ ); +}; diff --git a/src/features/onboarding/steps/step-db-settings.tsx b/src/features/onboarding/steps/step-db-settings.tsx new file mode 100644 index 00000000..d57a4e56 --- /dev/null +++ b/src/features/onboarding/steps/step-db-settings.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { DbGrid } from "@/features/onboarding/components/db-settings/db-grid"; +import { DbDetail } from "@/features/onboarding/components/db-settings/db-detail"; +import { DbSection } from "@/features/onboarding/components/db-settings/db-section"; +import { useApplyDbSettings } from "@/features/onboarding/hooks/use-apply-db-settings"; +import type { + OnboardingChannel, + OnboardingDatabase, + OnboardingDbSettings, + OnboardingProjectData, + SectionKind, +} from "@/features/onboarding/types"; + +type Phase = + | { kind: "grid" } + | { kind: "db"; dbId: string } + | { kind: "section"; dbId: string; section: SectionKind }; + +export const StepDbSettings = () => { + const { next, updateContext, state } = useOnboarding(); + const [phase, setPhase] = useState({ kind: "grid" }); + + const project = (state?.context.flowData.project ?? { + databaseIds: [], + }) as OnboardingProjectData; + const databases = (state?.context.flowData.databases ?? + []) as OnboardingDatabase[]; + const notifiers = (state?.context.flowData.notifiers ?? + []) as OnboardingChannel[]; + const storages = (state?.context.flowData.storages ?? + []) as OnboardingChannel[]; + const dbSettings = (state?.context.flowData.dbSettings ?? {}) as Record< + string, + OnboardingDbSettings + >; + + const databaseIds = project.databaseIds; + const applyMutation = useApplyDbSettings(); + + if (!databaseIds || databaseIds.length === 0) return null; + + const getDb = (id: string) => databases.find((d) => d.id === id); + + const isSectionConfigured = (dbId: string, section: SectionKind): boolean => { + const s = dbSettings[dbId]; + if (!s) return false; + switch (section) { + case "retention": + return !!s.retention; + case "scheduling": + return s.backupMethod === "automatic"; + case "notifications": + return (s.notificationPolicies?.length ?? 0) > 0; + case "storage": + return (s.storagePolicies?.length ?? 0) > 0; + } + }; + + const SECTION_KINDS: SectionKind[] = [ + "retention", + "scheduling", + "notifications", + "storage", + ]; + + const isDbConfigured = (dbId: string) => + SECTION_KINDS.some((k) => isSectionConfigured(dbId, k)); + + const updateDbSettings = async ( + dbId: string, + patch: Partial, + ) => { + const updated = { + ...dbSettings, + [dbId]: { ...(dbSettings[dbId] ?? {}), ...patch }, + }; + await updateContext({ + flowData: { ...state?.context.flowData, dbSettings: updated }, + }); + }; + + const handleApplyToAll = async (dbId: string) => { + const settings = dbSettings[dbId] ?? {}; + const otherDbIds = databaseIds.filter((id) => id !== dbId); + const succeededIds: string[] = []; + + for (const targetId of otherDbIds) { + try { + await applyMutation.mutateAsync({ + databaseId: targetId, + section: "all", + retention: settings.retention, + backupMethod: settings.backupMethod, + backupCron: settings.backupCron, + notificationPolicies: settings.notificationPolicies, + storagePolicies: settings.storagePolicies, + }); + succeededIds.push(targetId); + } catch (err) { + console.error("Failed to apply settings to database", targetId, err); + } + } + + if (succeededIds.length > 0) { + const updatedSettings = { ...dbSettings }; + succeededIds.forEach((id) => { + updatedSettings[id] = { ...(dbSettings[id] ?? {}), ...settings }; + }); + await updateContext({ + flowData: { ...state?.context.flowData, dbSettings: updatedSettings }, + }); + } + + if (succeededIds.length === otherDbIds.length) { + toast.success("Settings applied to all databases."); + setPhase({ kind: "grid" }); + } else if (succeededIds.length > 0) { + toast.warning( + `Settings applied to ${succeededIds.length} of ${otherDbIds.length} databases.`, + ); + setPhase({ kind: "grid" }); + } + }; + + if (phase.kind === "grid") + return ( + setPhase({ kind: "db", dbId })} + onContinue={() => next()} + /> + ); + + if (phase.kind === "db") { + const { dbId } = phase; + return ( + isSectionConfigured(dbId, s)} + isMultiDb={databaseIds.length > 1} + hasAnyConfigured={SECTION_KINDS.some((k) => + isSectionConfigured(dbId, k), + )} + isApplyingToAll={applyMutation.isPending} + onSelectSection={(section) => + setPhase({ kind: "section", dbId, section }) + } + onApplyToAll={() => handleApplyToAll(dbId)} + onBack={() => setPhase({ kind: "grid" })} + /> + ); + } + + if (phase.kind === "section") { + const { dbId, section } = phase; + return ( + setPhase({ kind: "db", dbId })} + onSaved={() => setPhase({ kind: "db", dbId })} + /> + ); + } + + return null; +}; diff --git a/src/features/onboarding/steps/step-defaults.tsx b/src/features/onboarding/steps/step-defaults.tsx new file mode 100644 index 00000000..41804072 --- /dev/null +++ b/src/features/onboarding/steps/step-defaults.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { + OnboardingChannel, + OnboardingDefaultsData, +} from "@/features/onboarding/types"; +import { getChannelIcon } from "@/features/channel/components/channels-helpers"; +import { updateNotificationSettingsAction } from "@/features/settings/actions/notification.action"; +import { updateStorageSettingsAction } from "@/features/settings/actions/storage.action"; + +export const StepDefaults = () => { + const { next, updateContext, state } = useOnboarding(); + const notifiers = (state?.context.flowData.notifiers ?? + []) as OnboardingChannel[]; + const storages = (state?.context.flowData.storages ?? + []) as OnboardingChannel[]; + const existingDefaults = (state?.context.flowData.defaults ?? + {}) as OnboardingDefaultsData; + + const [notifierId, setNotifierId] = useState( + existingDefaults.notifierId || undefined, + ); + const [storageId, setStorageId] = useState( + existingDefaults.storageId || undefined, + ); + + const selectNotifier = async (value: string) => { + const prev = notifierId; + setNotifierId(value); + try { + await updateNotificationSettingsAction({ + name: "system", + data: { notificationChannelId: value }, + }); + await updateContext({ + flowData: { + ...state?.context.flowData, + defaults: { notifierId: value, storageId }, + }, + }); + } catch { + setNotifierId(prev); + toast.error("Failed to save default notifier"); + } + }; + + const selectStorage = async (value: string) => { + const prev = storageId; + setStorageId(value); + try { + await updateStorageSettingsAction({ + name: "system", + data: { storageChannelId: value, encryption: false }, + }); + await updateContext({ + flowData: { + ...state?.context.flowData, + defaults: { notifierId, storageId: value }, + }, + }); + } catch { + setStorageId(prev); + toast.error("Failed to save default storage"); + } + }; + + const onContinue = async () => { + await updateContext({ + flowData: { + ...state?.context.flowData, + defaults: { notifierId, storageId }, + }, + }); + await next(); + }; + + const selectedNotifier = notifiers.find((n) => n.id === notifierId); + const selectedStorage = storages.find((s) => s.id === storageId); + + return ( +
+
+

Set your defaults

+

+ Optional — choose the default notifier and storage. +

+
+ +
+
+ + +
+ +
+ + +
+
+ + +
+ ); +}; diff --git a/src/features/onboarding/steps/step-finish.tsx b/src/features/onboarding/steps/step-finish.tsx new file mode 100644 index 00000000..45e1d5ae --- /dev/null +++ b/src/features/onboarding/steps/step-finish.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import confetti from "canvas-confetti"; +import { CheckCircle2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useMarkOnboardingDone } from "@/features/onboarding/hooks/use-mark-onboarding-done"; + +export const StepFinish = () => { + const router = useRouter(); + const fired = useRef(false); + const mutation = useMarkOnboardingDone(); + const [isRedirecting, setIsRedirecting] = useState(false); + + useEffect(() => { + if (fired.current) return; + fired.current = true; + confetti({ particleCount: 150, spread: 80, origin: { y: 0.6 } }); + }, []); + + return ( +
+ +

You're all set!

+

+ Your workspace is ready to use. +

+ +
+ ); +}; diff --git a/src/features/onboarding/steps/step-invite-members.tsx b/src/features/onboarding/steps/step-invite-members.tsx new file mode 100644 index 00000000..8104e392 --- /dev/null +++ b/src/features/onboarding/steps/step-invite-members.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { X } from "lucide-react"; +import { OnboardingMember } from "@/features/onboarding/types"; + +export const StepInviteMembers = () => { + const { next, updateContext, state } = useOnboarding(); + const [email, setEmail] = useState(""); + const [members, setMembers] = useState([]); + + const addMember = () => { + if (!email.trim()) return; + setMembers((prev) => [...prev, { email: email.trim(), role: "member" }]); + setEmail(""); + }; + + const removeMember = (target: string) => { + setMembers((prev) => prev.filter((m) => m.email !== target)); + }; + + const onContinue = async () => { + await updateContext({ flowData: { ...state?.context.flowData, members } }); + await next(); + }; + + return ( +
+
+

Invite your team

+

+ Optional — you can always invite people later. +

+
+
+ setEmail(e.target.value)} + placeholder="teammate@portabase.io" + onKeyDown={(e) => e.key === "Enter" && addMember()} + /> + +
+
+ {members.map((member) => ( + + {member.email} + + + ))} +
+ +
+ ); +}; diff --git a/src/features/onboarding/steps/step-login.tsx b/src/features/onboarding/steps/step-login.tsx new file mode 100644 index 00000000..6487f800 --- /dev/null +++ b/src/features/onboarding/steps/step-login.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useOnboarding } from "@onboardjs/react"; +import { useMutation } from "@tanstack/react-query"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Globe, Mail, KeyRound, ShieldAlert } from "lucide-react"; +import { Icon } from "@iconify/react"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; +import { + useZodForm, + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { signIn, useSession } from "@/lib/auth/auth-client"; +import type { OnboardingMeta } from "@/features/onboarding/types"; + +const LoginSchema = z.object({ + email: z.email("Invalid email"), + password: z.string().min(1, "Password required"), +}); + +type LoginValues = z.infer; + +export const StepLogin = () => { + const { next, state } = useOnboarding(); + const router = useRouter(); + const meta = state?.context.flowData.meta as OnboardingMeta | undefined; + const { data: session } = useSession(); + + const passkeyEnabled = meta?.passkeyEnabled ?? false; + const emailPasswordEnabled = meta?.emailPasswordEnabled ?? false; + const hasAnySsoProvider = (meta?.ssoProviders?.length ?? 0) > 0; + const hasAnyOAuthProvider = (meta?.oauthProviders?.length ?? 0) > 0; + const hasAnyAuthMethod = + emailPasswordEnabled || passkeyEnabled || hasAnySsoProvider || hasAnyOAuthProvider; + + useEffect(() => { + if (session?.user) { + router.push("/welcome"); + } + }, [session?.user, router]); + + const form = useZodForm({ schema: LoginSchema }); + + const passkeyMutation = useMutation({ + mutationFn: async () => { + const result = await (signIn as any).passkey(); + if (result?.error) + throw new Error(result.error.message ?? "Passkey sign in failed"); + router.push("/welcome"); + }, + onError: (err: Error) => { + toast.error(err.message); + }, + }); + + const loginMutation = useMutation({ + mutationFn: async (values: LoginValues) => { + const result = await signIn.email({ + email: values.email, + password: values.password, + }); + if (result.error) + throw new Error(result.error.message ?? "Sign in failed"); + router.push("/welcome"); + }, + onError: (err: Error) => { + toast.error(err.message); + }, + }); + + const handleOAuth = async (providerId: string) => { + const result = await signIn.social({ + provider: providerId as any, + callbackURL: "/welcome", + }); + if (result?.error) + toast.error(result.error.message ?? "OAuth sign in failed"); + }; + + const handleSso = async (providerId: string) => { + const result = await signIn.sso({ + providerId, + providerType: "oidc", + callbackURL: "/welcome", + }); + if (result?.error) + toast.error(result.error.message ?? "SSO sign in failed"); + }; + + if (!hasAnyAuthMethod) { + return ( +
+
+ +
+
+

Configuration needed

+

+ Please enable an authentication method in your environment variables + to continue. +

+
+
+

Set at least one of these:

+
    +
  • + •{" "} + + AUTH_EMAIL_PASSWORD_ENABLED=true + +
  • +
  • + •{" "} + + AUTH_PASSKEY_ENABLED=true + +
  • +
  • • Or configure an SSO provider
  • +
+
+
+ ); + } + + if (!meta?.hasExistingUsers) { + return ( +
+
+

Welcome to Portabase

+

+ Set up your instance by creating the first account. +

+
+ {(hasAnySsoProvider || hasAnyOAuthProvider) && ( +
+ {meta?.ssoProviders.map((provider) => ( + + ))} + {meta?.oauthProviders.map((provider) => ( + + ))} +
+ )} + {(emailPasswordEnabled || passkeyEnabled) && ( + + )} +
+ ); + } + + return ( +
+
+

Welcome back

+

+ {meta?.defaultUserMode + ? "Sign in to continue onboarding." + : "Your session expired. Sign in to continue where you left off."} +

+
+ {(hasAnySsoProvider || hasAnyOAuthProvider) && !meta?.defaultUserMode && ( +
+ {meta?.ssoProviders.map((provider) => ( + + ))} + {meta?.oauthProviders.map((provider) => ( + + ))} +
+ )} + {passkeyEnabled && ( + + )} + {emailPasswordEnabled && ( +
loginMutation.mutateAsync(values)} + > + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + )} +
+ ); +}; diff --git a/src/features/onboarding/steps/step-notifier.tsx b/src/features/onboarding/steps/step-notifier.tsx new file mode 100644 index 00000000..2ac5e399 --- /dev/null +++ b/src/features/onboarding/steps/step-notifier.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { ArrowLeft, Check, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + useZodForm, + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { notificationProviders } from "@/features/channel/components/channels-notification-helper"; +import { renderChannelForm } from "@/features/channel/components/channels-helpers"; +import { NotificationChannelFormSchema } from "@/features/channel/schemas/channel-form.schema"; +import { useAddNotifier } from "@/features/onboarding/hooks/use-add-notifier"; +import { useRemoveNotifier } from "@/features/onboarding/hooks/use-remove-notifier"; +import type { OnboardingChannel } from "@/features/onboarding/types"; + +type Phase = { kind: "grid" } | { kind: "configuring"; provider: string }; + +export const StepNotifier = () => { + const { next, updateContext, state } = useOnboarding(); + const notifiers = (state?.context.flowData.notifiers ?? + []) as OnboardingChannel[]; + const [phase, setPhase] = useState({ kind: "grid" }); + const form = useZodForm({ schema: NotificationChannelFormSchema }); + + const addNotifier = useAddNotifier(); + const removeNotifier = useRemoveNotifier(); + + const startConfiguring = (provider: string) => { + if (notifiers.some((c) => c.provider === provider)) return; + form.reset({ provider, enabled: true, name: "", config: {} } as any); + setPhase({ kind: "configuring", provider }); + }; + + const onContinue = async () => { + await updateContext({ + flowData: { ...state?.context.flowData, notifiers }, + }); + await next(); + }; + + if (phase.kind === "configuring") { + const providerDetails = notificationProviders.find( + (p) => p.value === phase.provider, + ); + const Icon = providerDetails?.icon; + + return ( +
+
+ {Icon && ( +
+ +
+ )} +

+ Configuring {providerDetails?.label} +

+ +
+
{ + const details = notificationProviders.find( + (p) => p.value === values.provider, + ); + addNotifier.mutate( + { + provider: values.provider, + name: values.name, + config: values.config, + label: details?.label ?? values.provider, + }, + { + onSuccess: () => { + form.reset({ enabled: true } as any); + setPhase({ kind: "grid" }); + }, + }, + ); + }} + > + ( + + Channel name * + + + + + + )} + /> + ( + + )} + /> + {renderChannelForm(phase.provider, form)} + + +
+ ); + } + + const configuredProviderIds = notifiers.map((c) => c.provider); + const availableProviders = notificationProviders.filter((p) => !p.preview); + + return ( +
+
+

Connect a notifier

+

+ Optional — get notified about backups, restores and health checks. +

+
+ + {notifiers.length > 0 && ( +
+ {notifiers.map((ch) => { + const details = notificationProviders.find( + (p) => p.value === ch.provider, + ); + const Icon = details?.icon; + return ( +
+ {Icon && ( +
+ +
+ )} + + {ch.name} ({ch.label}) + + +
+ ); + })} +
+ )} + +
+ {availableProviders.map((provider) => { + const Icon = provider.icon; + const isConfigured = configuredProviderIds.includes(provider.value); + return ( + + ); + })} +
+ + +
+ ); +}; diff --git a/src/features/onboarding/steps/step-org-create.tsx b/src/features/onboarding/steps/step-org-create.tsx new file mode 100644 index 00000000..7601b716 --- /dev/null +++ b/src/features/onboarding/steps/step-org-create.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { useCreateOrg } from "@/features/onboarding/hooks/use-create-org"; + +export const StepOrgCreate = () => { + const { state } = useOnboarding(); + const existingOrg = state?.context.flowData.org; + const isEditMode = !!existingOrg; + const [name, setName] = useState(existingOrg?.name ?? ""); + + const mutation = useCreateOrg(); + + return ( +
+
+

+ {isEditMode ? "Edit your organisation" : "Create your organisation"} +

+

+ {isEditMode + ? "Rename your organisation." + : "This step can't be skipped."} +

+
+
+ + setName(e.target.value)} + placeholder="Acme Inc." + /> +
+ +
+ ); +}; diff --git a/src/features/onboarding/steps/step-project-create.tsx b/src/features/onboarding/steps/step-project-create.tsx new file mode 100644 index 00000000..e0b8d3f6 --- /dev/null +++ b/src/features/onboarding/steps/step-project-create.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { Check, Database, Loader2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { useCreateProject } from "@/features/onboarding/hooks/use-create-project"; +import type { + OnboardingDatabase, + OnboardingProjectData, +} from "@/features/onboarding/types"; + +export const StepProjectCreate = () => { + const { state, next } = useOnboarding(); + const existingProject = state?.context.flowData.project as + | OnboardingProjectData + | undefined; + const databases = (state?.context.flowData.databases ?? + []) as OnboardingDatabase[]; + const isUpdateMode = !!existingProject; + + const [name, setName] = useState(existingProject?.name ?? ""); + const [databaseIds, setDatabaseIds] = useState( + existingProject?.databaseIds ?? [], + ); + const [loadingDbId, setLoadingDbId] = useState(null); + + const mutation = useCreateProject(); + + const toggleDb = (id: string) => { + setLoadingDbId(id); + const newDbIds = databaseIds.includes(id) + ? databaseIds.filter((v) => v !== id) + : [...databaseIds, id]; + setDatabaseIds(newDbIds); + mutation.mutate( + { name: name || "My project", databaseIds: newDbIds }, + { onSettled: () => setLoadingDbId(null) }, + ); + }; + + return ( +
+
+

+ {isUpdateMode ? "Update project" : "Create a project"} +

+

+ Optional — group databases under a project. +

+
+
+ + setName(e.target.value)} + placeholder="My project" + /> +
+ {databases.length > 0 && ( +
+ +
+ {databases.map((db) => { + const isSelected = databaseIds.includes(db.id); + const isCurrentLoading = loadingDbId === db.id; + + return ( + + ); + })} +
+
+ )} + +
+ ); +}; diff --git a/src/features/onboarding/steps/step-security.tsx b/src/features/onboarding/steps/step-security.tsx new file mode 100644 index 00000000..a34e4c63 --- /dev/null +++ b/src/features/onboarding/steps/step-security.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { KeyRound, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth/auth-client"; +import { TwoFactorSetupContent } from "@/features/profile/components/two-factor-setup-content"; +import type { OnboardingMeta } from "@/features/onboarding/types"; + +export const StepSecurity = () => { + const { next, updateContext, state } = useOnboarding(); + const meta = state?.context.flowData.meta as OnboardingMeta | undefined; + const passkeyEnabled = meta?.passkeyEnabled ?? false; + + const [phase, setPhase] = useState<"choose" | "two-factor">("choose"); + + const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({ + mutationFn: async () => { + const result = await authClient.passkey.addPasskey({ name: "My Passkey" }); + if (result?.error) throw result.error; + return result; + }, + onSuccess: async () => { + toast.success("Passkey added successfully."); + await updateContext({ flowData: { ...state?.context.flowData, security: { method: "passkey" } } }); + await next(); + }, + onError: (e: any) => toast.error(e.message || "Failed to add passkey"), + }); + + const handleTwoFactorSuccess = async () => { + await updateContext({ flowData: { ...state?.context.flowData, security: { method: "two-factor" } } }); + await next(); + }; + + if (phase === "two-factor") { + return ( +
+
+

Set up two-factor

+

+ Add an extra layer of security to your account. +

+
+ +
+ ); + } + + return ( +
+
+

Secure your account

+

+ Choose a method to protect your account. +

+
+ + {passkeyEnabled && ( + + )} + + + + +
+ ); +}; diff --git a/src/features/onboarding/steps/step-storage.tsx b/src/features/onboarding/steps/step-storage.tsx new file mode 100644 index 00000000..b347613a --- /dev/null +++ b/src/features/onboarding/steps/step-storage.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { ArrowLeft, Check, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + useZodForm, + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { storageProviders } from "@/features/channel/components/channels-storage-helper"; +import { renderChannelForm } from "@/features/channel/components/channels-helpers"; +import { StorageChannelFormSchema } from "@/features/channel/schemas/channel-form.schema"; +import { useAddStorage } from "@/features/onboarding/hooks/use-add-storage"; +import { useRemoveStorage } from "@/features/onboarding/hooks/use-remove-storage"; +import type { OnboardingChannel } from "@/features/onboarding/types"; + +type Phase = { kind: "grid" } | { kind: "configuring"; provider: string }; + +export const StepStorage = () => { + const { next, updateContext, state } = useOnboarding(); + const storages = (state?.context.flowData.storages ?? + []) as OnboardingChannel[]; + const [phase, setPhase] = useState({ kind: "grid" }); + const form = useZodForm({ schema: StorageChannelFormSchema }); + + const addStorage = useAddStorage(); + const removeStorage = useRemoveStorage(); + + const startConfiguring = (provider: string) => { + if (storages.some((c) => c.provider === provider)) return; + form.reset({ provider, enabled: true, name: "", config: {} } as any); + setPhase({ kind: "configuring", provider }); + }; + + const onContinue = async () => { + await updateContext({ flowData: { ...state?.context.flowData, storages } }); + await next(); + }; + + if (phase.kind === "configuring") { + const providerDetails = storageProviders.find( + (p) => p.value === phase.provider, + ); + const Icon = providerDetails?.icon; + + return ( +
+
+ {Icon && ( +
+ +
+ )} +

+ Configuring {providerDetails?.label} +

+ +
+
{ + const details = storageProviders.find( + (p) => p.value === values.provider, + ); + addStorage.mutate( + { + provider: values.provider, + name: values.name, + config: values.config, + label: details?.label ?? values.provider, + }, + { + onSuccess: () => { + form.reset({ enabled: true } as any); + setPhase({ kind: "grid" }); + }, + }, + ); + }} + > + ( + + Channel name * + + + + + + )} + /> + ( + + )} + /> + {renderChannelForm(phase.provider, form)} + + +
+ ); + } + + const availableProviders = storageProviders.filter( + (p) => !p.preview && p.value !== "local", + ); + const configuredProviderIds = storages.map((c) => c.provider); + + return ( +
+
+

Connect an external storage

+

+ Optional — link an S3-compatible bucket or other external storage to keep your backups safe and off-instance. +

+
+ + {storages.length > 0 && ( +
+ {storages.map((ch) => { + const details = storageProviders.find( + (p) => p.value === ch.provider, + ); + const Icon = details?.icon; + return ( +
+ {Icon && ( +
+ +
+ )} + + {ch.name} ({ch.label}) + + {ch.organizationId !== null && ( + + )} +
+ ); + })} +
+ )} + +
+ {availableProviders.map((provider) => { + const Icon = provider.icon; + const isConfigured = configuredProviderIds.includes(provider.value); + return ( + + ); + })} +
+ + +
+ ); +}; diff --git a/src/features/onboarding/types/index.ts b/src/features/onboarding/types/index.ts new file mode 100644 index 00000000..7e752b79 --- /dev/null +++ b/src/features/onboarding/types/index.ts @@ -0,0 +1,127 @@ +export type OnboardingSsoProvider = { id: string; label: string; icon: string; description?: string }; +export type OnboardingOAuthProvider = { id: string; label: string; icon: string }; + +export type OnboardingMeta = { + passkeyEnabled: boolean; + hasExistingUsers: boolean; + ssoProviders: OnboardingSsoProvider[]; + oauthProviders: OnboardingOAuthProvider[]; + defaultUserMode: boolean; + resumeStepId: string; + emailPasswordEnabled: boolean; +}; + +export type OnboardingAccountData = { + firstName: string; + lastName: string; + email: string; +}; + +export type OnboardingSecurityData = { + method: "passkey" | "two-factor" | "skipped"; +}; + +export type OnboardingOrgData = { + id: string; + name: string; +}; + +export type OnboardingMember = { + email: string; + role: "member" | "admin"; +}; + +export type OnboardingChannel = { + id: string; + provider: string; + label: string; + name: string; + config: Record; + organizationId?: string | null; +}; + +export type OnboardingDefaultsData = { + notifierId?: string; + storageId?: string; +}; + +export type OnboardingAgent = { + id: string; + name: string; + edgeKey?: string; + connected?: boolean; + notifierId?: string; + storageId?: string; +}; + +export type OnboardingDatabase = { + id: string; + name: string; + engine: "postgres" | "mysql" | "mongodb"; +}; + +export type SectionKind = + | "retention" + | "scheduling" + | "notifications" + | "storage"; + +export type EventKind = + | "error_backup" + | "error_restore" + | "success_restore" + | "success_backup" + | "weekly_report" + | "error_health_agent" + | "error_health_database"; + +export type OnboardingNotificationPolicy = { + channelId: string; + eventKinds: EventKind[]; + enabled: boolean; +}; + +export type OnboardingStoragePolicy = { + channelId: string; + enabled: boolean; +}; + +export type OnboardingDbSettings = { + retention?: { + type?: "count" | "days" | "gfs"; + count: number; + days: number; + gfs: { + daily: number; + weekly: number; + monthly: number; + yearly: number; + }; + }; + backupMethod?: "manual" | "automatic"; + backupCron?: string; + notificationPolicies?: OnboardingNotificationPolicy[]; + storagePolicies?: OnboardingStoragePolicy[]; +}; + +export type OnboardingProjectData = { + id: string; + name: string; + description: string; + databaseIds: string[]; +}; + +export type OnboardingFlowData = { + meta?: OnboardingMeta; + account?: OnboardingAccountData; + security?: OnboardingSecurityData; + org?: OnboardingOrgData; + members?: OnboardingMember[]; + notifiers?: OnboardingChannel[]; + storages?: OnboardingChannel[]; + defaults?: OnboardingDefaultsData; + agents?: OnboardingAgent[]; + databases?: OnboardingDatabase[]; + project?: OnboardingProjectData; + dbSettings?: Record; +}; diff --git a/src/features/onboarding/utils/.gitkeep b/src/features/onboarding/utils/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/features/organizations/actions/add-member.action.ts b/src/features/organizations/actions/add-member.action.ts new file mode 100644 index 00000000..b5697fa5 --- /dev/null +++ b/src/features/organizations/actions/add-member.action.ts @@ -0,0 +1,46 @@ +"use server"; + +import { ServerActionResult } from "@/types/action-type"; +import { z } from "zod"; +import { auth } from "@/lib/auth/auth"; +import { MemberRoleType } from "@/types/common"; +import { Member } from "better-auth/plugins/organization"; +import { userAction } from "@/lib/safe-actions/actions"; + +export const addMemberOrganizationAction = userAction + .schema( + z.object({ + userId: z.string(), + organizationId: z.string(), + role: z.string(), + }), + ) + .action( + async ({ parsedInput }): Promise> => { + try { + const data = await auth.api.addMember({ + body: { + userId: parsedInput.userId, + role: parsedInput.role as MemberRoleType, + organizationId: parsedInput.organizationId, + }, + }); + + return { + success: true, + value: data, + actionSuccess: { + message: "Member added successfully", + }, + }; + } catch (error) { + return { + success: false, + actionError: { + message: "An error occurred while addinng member", + cause: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + }, + ); diff --git a/src/features/organizations/channels-organization.action.ts b/src/features/organizations/actions/channels-organization.action.ts similarity index 100% rename from src/features/organizations/channels-organization.action.ts rename to src/features/organizations/actions/channels-organization.action.ts diff --git a/src/features/organizations/organization.action.ts b/src/features/organizations/actions/organization.action.ts similarity index 93% rename from src/features/organizations/organization.action.ts rename to src/features/organizations/actions/organization.action.ts index f32b3cf0..22c1a861 100644 --- a/src/features/organizations/organization.action.ts +++ b/src/features/organizations/actions/organization.action.ts @@ -1,7 +1,7 @@ "use server"; import {userAction} from "@/lib/safe-actions/actions"; -import {CreateOrganizationSchema, UpdateOrganizationSchema} from "@/features/organizations/organization.schema"; +import {CreateOrganizationSchema, UpdateOrganizationSchema} from "@/features/organizations/schemas/organization.schema"; import {ServerActionResult} from "@/types/action-type"; import {z} from "zod"; import {db} from "@/db"; @@ -10,6 +10,15 @@ import {auth, checkSlugOrganization, createOrganization} from "@/lib/auth/auth"; import {slugify} from "@/utils/slugify"; import {Organization} from "@/db/schema/03_organization"; import * as drizzleDb from "@/db"; +import {getUserOrganization} from "@/db/services/organization"; + +export const getMyOrganizationAction = userAction.schema(z.object({})).action(async ({ ctx }): Promise> => { + const org = await getUserOrganization(ctx.user.id); + if (!org) { + return { success: false, actionError: { message: "No organisation found.", status: 404 } }; + } + return { success: true, value: org as Organization }; +}); export const createOrganizationAction = userAction.schema(CreateOrganizationSchema).action(async ({parsedInput}): Promise> => { try { diff --git a/src/features/organizations/role-member.action.ts b/src/features/organizations/actions/role-member.action.ts similarity index 95% rename from src/features/organizations/role-member.action.ts rename to src/features/organizations/actions/role-member.action.ts index 6be44db7..764c7252 100644 --- a/src/features/organizations/role-member.action.ts +++ b/src/features/organizations/actions/role-member.action.ts @@ -7,7 +7,7 @@ import {db as dbClient} from "@/db"; import * as drizzleDb from "@/db"; import {and, eq} from "drizzle-orm"; import {withUpdatedAt} from "@/db/utils"; -import {RoleSchemaMember} from "@/features/organizations/member.schema"; +import {RoleSchemaMember} from "@/features/organizations/schemas/member.schema"; export const updateMemberRoleAdminAction = userAction.schema( diff --git a/src/features/organizations/update-member.action.ts b/src/features/organizations/actions/update-member.action.ts similarity index 94% rename from src/features/organizations/update-member.action.ts rename to src/features/organizations/actions/update-member.action.ts index 8c4b6623..0b87924e 100644 --- a/src/features/organizations/update-member.action.ts +++ b/src/features/organizations/actions/update-member.action.ts @@ -5,7 +5,7 @@ import {auth} from "@/lib/auth/auth"; import {ServerActionResult} from "@/types/action-type"; import {Member} from "better-auth/plugins"; import {headers} from "next/headers"; -import {RoleSchemaMember} from "@/features/organizations/member.schema"; +import {RoleSchemaMember} from "@/features/organizations/schemas/member.schema"; export const updateMemberRoleAction = userAction.schema( diff --git a/src/features/organizations/add-member.action.ts b/src/features/organizations/add-member.action.ts deleted file mode 100644 index 0db49ff3..00000000 --- a/src/features/organizations/add-member.action.ts +++ /dev/null @@ -1,44 +0,0 @@ -"use server"; - -import { ServerActionResult } from "@/types/action-type"; -import { z } from "zod"; -import { auth } from "@/lib/auth/auth"; -import { MemberRoleType } from "@/types/common"; -import { Member } from "better-auth/plugins/organization"; -import {userAction} from "@/lib/safe-actions/actions"; - -export const addMemberOrganizationAction = userAction - .schema( - z.object({ - userId: z.string(), - organizationId: z.string(), - role: z.string(), - }) - ) - .action(async ({ parsedInput }): Promise> => { - try { - const data = await auth.api.addMember({ - body: { - userId: parsedInput.userId, - role: parsedInput.role as MemberRoleType, - organizationId: parsedInput.organizationId, - }, - }); - - return { - success: true, - value: data, - actionSuccess: { - message: "Member added successfully", - }, - }; - } catch (error) { - return { - success: false, - actionError: { - message: "An error occurred while addinng member", - cause: error instanceof Error ? error.message : "Unknown error", - }, - }; - } - }); diff --git a/src/features/organizations/admin-org-columns.tsx b/src/features/organizations/components/admin-org-columns.tsx similarity index 97% rename from src/features/organizations/admin-org-columns.tsx rename to src/features/organizations/components/admin-org-columns.tsx index ba2f37de..d2ff86c7 100644 --- a/src/features/organizations/admin-org-columns.tsx +++ b/src/features/organizations/components/admin-org-columns.tsx @@ -1,6 +1,6 @@ "use client"; import {ColumnDef} from "@tanstack/react-table"; -import {ButtonDeleteOrganization} from "@/features/organizations/admin-organization-delete-button"; +import {ButtonDeleteOrganization} from "@/features/organizations/components/admin-organization-delete-button"; import Link from "next/link"; import {Settings} from "lucide-react"; import {buttonVariants} from "@/components/ui/button"; diff --git a/src/features/organizations/admin-organization-add-modal.tsx b/src/features/organizations/components/admin-organization-add-modal.tsx similarity index 97% rename from src/features/organizations/admin-organization-add-modal.tsx rename to src/features/organizations/components/admin-organization-add-modal.tsx index 2eb471e3..da423acb 100644 --- a/src/features/organizations/admin-organization-add-modal.tsx +++ b/src/features/organizations/components/admin-organization-add-modal.tsx @@ -1,6 +1,6 @@ "use client" -import {CreateOrganizationModal} from "@/features/organizations/organization-create-modal"; +import {CreateOrganizationModal} from "@/features/organizations/components/organization-create-modal"; import {useRouter} from "next/navigation"; import {authClient} from "@/lib/auth/auth-client"; import {useState} from "react"; diff --git a/src/features/organizations/admin-organization-delete-button.tsx b/src/features/organizations/components/admin-organization-delete-button.tsx similarity index 98% rename from src/features/organizations/admin-organization-delete-button.tsx rename to src/features/organizations/components/admin-organization-delete-button.tsx index bd715c1a..50a503eb 100644 --- a/src/features/organizations/admin-organization-delete-button.tsx +++ b/src/features/organizations/components/admin-organization-delete-button.tsx @@ -6,7 +6,7 @@ import {Trash2} from "lucide-react"; import {toast} from "sonner"; import {ButtonWithConfirm} from "@/components/common/button-with-confirm"; import {authClient} from "@/lib/auth/auth-client"; -import {deleteOrganizationAction} from "@/features/organizations/organization.action"; +import {deleteOrganizationAction} from "@/features/organizations/actions/organization.action"; export type ButtonDeleteFleetProps = { text?: string; diff --git a/src/features/organizations/admin-organization-form.tsx b/src/features/organizations/components/admin-organization-form.tsx similarity index 96% rename from src/features/organizations/admin-organization-form.tsx rename to src/features/organizations/components/admin-organization-form.tsx index 7845e5cc..8366dbd6 100644 --- a/src/features/organizations/admin-organization-form.tsx +++ b/src/features/organizations/components/admin-organization-form.tsx @@ -5,7 +5,7 @@ import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form"; -import { OrganizationSchema } from "@/features/organizations/admin-organization.schema"; +import { OrganizationSchema } from "@/features/organizations/schemas/admin-organization.schema"; import { ButtonWithLoading } from "@/components/common/button-with-loading"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth/auth-client"; diff --git a/src/features/organizations/admin-organization-list.tsx b/src/features/organizations/components/admin-organization-list.tsx similarity index 93% rename from src/features/organizations/admin-organization-list.tsx rename to src/features/organizations/components/admin-organization-list.tsx index 090a8555..e5dffce2 100644 --- a/src/features/organizations/admin-organization-list.tsx +++ b/src/features/organizations/components/admin-organization-list.tsx @@ -1,6 +1,6 @@ "use client" import { DataTable } from "@/components/common/data-table"; -import { organizationsListColumns } from "@/features/organizations/admin-org-columns"; +import { organizationsListColumns } from "@/features/organizations/components/admin-org-columns"; import {OrganizationWithMembers} from "@/db/schema/03_organization"; type AdminOrganizationListProps = { diff --git a/src/features/organizations/admin-organization-management.tsx b/src/features/organizations/components/admin-organization-management.tsx similarity index 96% rename from src/features/organizations/admin-organization-management.tsx rename to src/features/organizations/components/admin-organization-management.tsx index b7dbd08e..743d100e 100644 --- a/src/features/organizations/admin-organization-management.tsx +++ b/src/features/organizations/components/admin-organization-management.tsx @@ -6,17 +6,17 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization"; import { UpdateOrganizationForm -} from "@/features/organizations/update-organization-form"; +} from "@/features/organizations/components/update-organization-form"; import { OrganizationMemberCard -} from "@/features/organizations/organization-member-card"; +} from "@/features/organizations/components/organization-member-card"; import {useRouter, useSearchParams} from "next/navigation"; import {useEffect, useState} from "react"; import {capitalizeFirstLetter} from "@/utils/text"; import {User} from "@/db/schema/02_user"; import { OrganizationAddMemberModal -} from "@/features/organizations/organization-add-member-modal"; +} from "@/features/organizations/components/organization-add-member-modal"; import {cn} from "@/lib/utils"; type OrganizationManagementProps = { diff --git a/src/features/organizations/admin-organization-section.tsx b/src/features/organizations/components/admin-organization-section.tsx similarity index 91% rename from src/features/organizations/admin-organization-section.tsx rename to src/features/organizations/components/admin-organization-section.tsx index 992a889e..bb336e6e 100644 --- a/src/features/organizations/admin-organization-section.tsx +++ b/src/features/organizations/components/admin-organization-section.tsx @@ -1,8 +1,8 @@ "use client"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { AdminOrganizationList } from "@/features/organizations/admin-organization-list"; -import { AdminOrganizationAddModal } from "@/features/organizations/admin-organization-add-modal"; +import { AdminOrganizationList } from "@/features/organizations/components/admin-organization-list"; +import { AdminOrganizationAddModal } from "@/features/organizations/components/admin-organization-add-modal"; import {OrganizationWithMembers} from "@/db/schema/03_organization"; type AdminOrganizationSectionProps = { diff --git a/src/features/organizations/admin-organizations-table.tsx b/src/features/organizations/components/admin-organizations-table.tsx similarity index 90% rename from src/features/organizations/admin-organizations-table.tsx rename to src/features/organizations/components/admin-organizations-table.tsx index ca68900b..e7da201e 100644 --- a/src/features/organizations/admin-organizations-table.tsx +++ b/src/features/organizations/components/admin-organizations-table.tsx @@ -1,7 +1,7 @@ "use client" import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; import {OrganizationWithMembers} from "@/db/schema/03_organization"; -import {AdminOrganizationList} from "@/features/organizations/admin-organization-list"; +import {AdminOrganizationList} from "@/features/organizations/components/admin-organization-list"; export type AdminOrganizationsTableProps = { organizations: OrganizationWithMembers[]; diff --git a/src/features/organizations/channels-organization-form.tsx b/src/features/organizations/components/channels-organization-form.tsx similarity index 94% rename from src/features/organizations/channels-organization-form.tsx rename to src/features/organizations/components/channels-organization-form.tsx index 8d764782..2562e4b3 100644 --- a/src/features/organizations/channels-organization-form.tsx +++ b/src/features/organizations/components/channels-organization-form.tsx @@ -12,11 +12,11 @@ import {toast} from "sonner"; import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import { ChannelsOrganizationSchema, ChannelsOrganizationType -} from "@/features/organizations/channels-organization.schema"; +} from "@/features/organizations/schemas/channels-organization.schema"; import { updateNotificationChannelsOrganizationAction, updateStorageChannelsOrganizationAction -} from "@/features/organizations/channels-organization.action"; -import {ChannelKind} from "@/features/channel/channels-helpers"; +} from "@/features/organizations/actions/channels-organization.action"; +import {ChannelKind} from "@/features/channel/components/channels-helpers"; type ChannelOrganisationFormProps = { diff --git a/src/features/organizations/member-columns.tsx b/src/features/organizations/components/member-columns.tsx similarity index 95% rename from src/features/organizations/member-columns.tsx rename to src/features/organizations/components/member-columns.tsx index 5b3a2b66..fd5b8819 100644 --- a/src/features/organizations/member-columns.tsx +++ b/src/features/organizations/components/member-columns.tsx @@ -13,8 +13,8 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import {updateMemberRoleAction} from "@/features/organizations/update-member.action"; -import {RoleSchemaMember} from "@/features/organizations/member.schema"; +import {updateMemberRoleAction} from "@/features/organizations/actions/update-member.action"; +import {RoleSchemaMember} from "@/features/organizations/schemas/member.schema"; export const organizationMemberColumns: ColumnDef[] = [ { diff --git a/src/features/organizations/organization-add-member-form.tsx b/src/features/organizations/components/organization-add-member-form.tsx similarity index 95% rename from src/features/organizations/organization-add-member-form.tsx rename to src/features/organizations/components/organization-add-member-form.tsx index 49142d83..a9aa97d3 100644 --- a/src/features/organizations/organization-add-member-form.tsx +++ b/src/features/organizations/components/organization-add-member-form.tsx @@ -7,11 +7,11 @@ import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodFo import { AddMemberSchema, AddMemberSchemaType -} from "@/features/organizations/admin-organization.schema"; +} from "@/features/organizations/schemas/admin-organization.schema"; import {SearchInput} from "@/components/ui/search-input"; import { addMemberOrganizationAction -} from "@/features/organizations/add-member.action"; +} from "@/features/organizations/actions/add-member.action"; import {toast} from "sonner"; import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization"; import {User} from "@/db/schema/02_user"; diff --git a/src/features/organizations/organization-add-member-modal.tsx b/src/features/organizations/components/organization-add-member-modal.tsx similarity index 95% rename from src/features/organizations/organization-add-member-modal.tsx rename to src/features/organizations/components/organization-add-member-modal.tsx index 6e30fa76..d0109f53 100644 --- a/src/features/organizations/organization-add-member-modal.tsx +++ b/src/features/organizations/components/organization-add-member-modal.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/dialog"; import { OrganizationAddMemberForm -} from "@/features/organizations/organization-add-member-form"; +} from "@/features/organizations/components/organization-add-member-form"; import {useState} from "react"; import {UserPlus} from "lucide-react"; import {Button} from "@/components/ui/button"; diff --git a/src/features/organizations/organization-agents-tab.tsx b/src/features/organizations/components/organization-agents-tab.tsx similarity index 91% rename from src/features/organizations/organization-agents-tab.tsx rename to src/features/organizations/components/organization-agents-tab.tsx index abb57e0f..a1aa508d 100644 --- a/src/features/organizations/organization-agents-tab.tsx +++ b/src/features/organizations/components/organization-agents-tab.tsx @@ -2,8 +2,8 @@ import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {cn} from "@/lib/utils"; import {Agent} from "@/db/schema/08_agent"; import {CardsWithPagination} from "@/components/common/cards-with-pagination"; -import {AgentCard} from "@/features/agents/agent-card"; -import {AgentDialog} from "@/features/agents/agent-dialog"; +import {AgentCard} from "@/features/agents/components/agent-card"; +import {AgentDialog} from "@/features/agents/components/agent-dialog"; export type OrganizationAgentsTabProps = { organization: OrganizationWithMembers; diff --git a/src/features/organizations/organization-columns.tsx b/src/features/organizations/components/organization-columns.tsx similarity index 100% rename from src/features/organizations/organization-columns.tsx rename to src/features/organizations/components/organization-columns.tsx diff --git a/src/features/organizations/organization-combobox.tsx b/src/features/organizations/components/organization-combobox.tsx similarity index 99% rename from src/features/organizations/organization-combobox.tsx rename to src/features/organizations/components/organization-combobox.tsx index 2d90c9e0..97c786b2 100644 --- a/src/features/organizations/organization-combobox.tsx +++ b/src/features/organizations/components/organization-combobox.tsx @@ -12,7 +12,7 @@ import { import { SidebarMenuButton, useSidebar, } from "@/components/ui/sidebar" -import {CreateOrganizationModal} from "@/features/organizations/organization-create-modal" +import {CreateOrganizationModal} from "@/features/organizations/components/organization-create-modal" import {Skeleton} from "@/components/ui/skeleton" diff --git a/src/features/organizations/organization-create-modal.tsx b/src/features/organizations/components/organization-create-modal.tsx similarity index 97% rename from src/features/organizations/organization-create-modal.tsx rename to src/features/organizations/components/organization-create-modal.tsx index 396981ff..49fbf407 100644 --- a/src/features/organizations/organization-create-modal.tsx +++ b/src/features/organizations/components/organization-create-modal.tsx @@ -6,8 +6,8 @@ import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from "@/ import {Button} from "@/components/ui/button"; import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {Input} from "@/components/ui/input"; -import {CreateOrganizationSchema, CreateOrganizationType} from "@/features/organizations/organization.schema"; -import {createOrganizationAction} from "@/features/organizations/organization.action"; +import {CreateOrganizationSchema, CreateOrganizationType} from "@/features/organizations/schemas/organization.schema"; +import {createOrganizationAction} from "@/features/organizations/actions/organization.action"; import {useRouter} from "next/navigation"; import {useState} from "react"; import {authClient} from "@/lib/auth/auth-client"; diff --git a/src/features/organizations/organization-delete-button.tsx b/src/features/organizations/components/organization-delete-button.tsx similarity index 98% rename from src/features/organizations/organization-delete-button.tsx rename to src/features/organizations/components/organization-delete-button.tsx index 1f34e4f0..da85ba67 100644 --- a/src/features/organizations/organization-delete-button.tsx +++ b/src/features/organizations/components/organization-delete-button.tsx @@ -1,6 +1,6 @@ "use client"; import {ButtonWithConfirm} from "@/components/common/button-with-confirm"; -import {deleteOrganizationAction} from "@/features/organizations/organization.action"; +import {deleteOrganizationAction} from "@/features/organizations/actions/organization.action"; import {useMutation} from "@tanstack/react-query"; import {useRouter} from "next/navigation"; import {toast} from "sonner"; diff --git a/src/features/organizations/organization-delete-member-modal.tsx b/src/features/organizations/components/organization-delete-member-modal.tsx similarity index 100% rename from src/features/organizations/organization-delete-member-modal.tsx rename to src/features/organizations/components/organization-delete-member-modal.tsx diff --git a/src/features/organizations/organization-edit-dialog.tsx b/src/features/organizations/components/organization-edit-dialog.tsx similarity index 95% rename from src/features/organizations/organization-edit-dialog.tsx rename to src/features/organizations/components/organization-edit-dialog.tsx index 42915e91..8abd64e7 100644 --- a/src/features/organizations/organization-edit-dialog.tsx +++ b/src/features/organizations/components/organization-edit-dialog.tsx @@ -7,7 +7,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import {OrganizationForm} from "@/features/organizations/organization-form"; +import {OrganizationForm} from "@/features/organizations/components/organization-form"; import {useState} from "react"; import {buttonVariants} from "@/components/ui/button"; import {GearIcon} from "@radix-ui/react-icons"; diff --git a/src/features/organizations/organization-form.tsx b/src/features/organizations/components/organization-form.tsx similarity index 97% rename from src/features/organizations/organization-form.tsx rename to src/features/organizations/components/organization-form.tsx index b06fff71..c2afef00 100644 --- a/src/features/organizations/organization-form.tsx +++ b/src/features/organizations/components/organization-form.tsx @@ -18,11 +18,11 @@ import {MultiSelect} from "@/components/common/multi-select"; import { UpdateOrganizationSchema, UpdateOrganizationType -} from "@/features/organizations/organization.schema"; +} from "@/features/organizations/schemas/organization.schema"; import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization"; import { updateOrganizationAction -} from "@/features/organizations/organization.action"; +} from "@/features/organizations/actions/organization.action"; import {toast} from "sonner"; import {User} from "@/db/schema/02_user"; import {authClient} from "@/lib/auth/auth-client"; diff --git a/src/features/organizations/organization-member-card.tsx b/src/features/organizations/components/organization-member-card.tsx similarity index 96% rename from src/features/organizations/organization-member-card.tsx rename to src/features/organizations/components/organization-member-card.tsx index d10491b9..9eccb8ef 100644 --- a/src/features/organizations/organization-member-card.tsx +++ b/src/features/organizations/components/organization-member-card.tsx @@ -12,12 +12,12 @@ import {Button} from "@/components/ui/button"; import {MoreHorizontal, Settings, Trash2} from "lucide-react"; import { OrganizationDeleteMemberModal -} from "@/features/organizations/organization-delete-member-modal"; +} from "@/features/organizations/components/organization-delete-member-modal"; import {useState} from "react"; import {authClient} from "@/lib/auth/auth-client"; import { OrganizationMemberChangeRoleModal -} from "@/features/organizations/organization-member-change-role"; +} from "@/features/organizations/components/organization-member-change-role"; import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization"; type OrganizationMemberCardProps = { diff --git a/src/features/organizations/organization-member-change-role.tsx b/src/features/organizations/components/organization-member-change-role.tsx similarity index 96% rename from src/features/organizations/organization-member-change-role.tsx rename to src/features/organizations/components/organization-member-change-role.tsx index 4812d567..12bceb74 100644 --- a/src/features/organizations/organization-member-change-role.tsx +++ b/src/features/organizations/components/organization-member-change-role.tsx @@ -19,8 +19,8 @@ import {MemberRoleType} from "@/types/common"; import {MemberWithUser} from "@/db/schema/03_organization"; import { updateMemberRoleAdminAction -} from "@/features/organizations/role-member.action"; -import {RoleSchemaMember} from "@/features/organizations/member.schema"; +} from "@/features/organizations/actions/role-member.action"; +import {RoleSchemaMember} from "@/features/organizations/schemas/member.schema"; type OrganizationMemberChangeRoleModalProps = { open: boolean; diff --git a/src/features/organizations/organization-members-table.tsx b/src/features/organizations/components/organization-members-table.tsx similarity index 92% rename from src/features/organizations/organization-members-table.tsx rename to src/features/organizations/components/organization-members-table.tsx index 5ff71306..285180d5 100644 --- a/src/features/organizations/organization-members-table.tsx +++ b/src/features/organizations/components/organization-members-table.tsx @@ -2,7 +2,7 @@ import {DataTable} from "@/components/common/data-table"; import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization"; import { organizationMemberColumns -} from "@/features/organizations/member-columns"; +} from "@/features/organizations/components/member-columns"; interface SettingsOrganizationMembersTableProps { organization: OrganizationWithMembers diff --git a/src/features/organizations/organization-notifiers-tab.tsx b/src/features/organizations/components/organization-notifiers-tab.tsx similarity index 93% rename from src/features/organizations/organization-notifiers-tab.tsx rename to src/features/organizations/components/organization-notifiers-tab.tsx index 1bb48f52..f856595a 100644 --- a/src/features/organizations/organization-notifiers-tab.tsx +++ b/src/features/organizations/components/organization-notifiers-tab.tsx @@ -4,8 +4,8 @@ import {CardsWithPagination} from "@/components/common/cards-with-pagination"; import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder"; import {useState} from "react"; import {cn} from "@/lib/utils"; -import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal"; -import {ChannelCard} from "@/features/channel/channel-card"; +import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal"; +import {ChannelCard} from "@/features/channel/components/channel-card"; export type OrganizationNotifiersTabProps = { organization: OrganizationWithMembers; diff --git a/src/features/organizations/organization-storages-tab.tsx b/src/features/organizations/components/organization-storages-tab.tsx similarity index 93% rename from src/features/organizations/organization-storages-tab.tsx rename to src/features/organizations/components/organization-storages-tab.tsx index 35794f91..820046e8 100644 --- a/src/features/organizations/organization-storages-tab.tsx +++ b/src/features/organizations/components/organization-storages-tab.tsx @@ -3,8 +3,8 @@ import {CardsWithPagination} from "@/components/common/cards-with-pagination"; import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder"; import {useState} from "react"; import {cn} from "@/lib/utils"; -import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal"; -import {ChannelCard} from "@/features/channel/channel-card"; +import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal"; +import {ChannelCard} from "@/features/channel/components/channel-card"; import {StorageChannel} from "@/db/schema/12_storage-channel"; export type OrganizationNotifiersTabProps = { diff --git a/src/features/organizations/organization-tabs.tsx b/src/features/organizations/components/organization-tabs.tsx similarity index 92% rename from src/features/organizations/organization-tabs.tsx rename to src/features/organizations/components/organization-tabs.tsx index b91ea24d..37166e31 100644 --- a/src/features/organizations/organization-tabs.tsx +++ b/src/features/organizations/components/organization-tabs.tsx @@ -5,20 +5,20 @@ import {useEffect, useState} from "react"; import {useRouter, useSearchParams} from "next/navigation"; import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization"; import {NotificationChannel} from "@/db/schema/09_notification-channel"; -import {useOrganizationPermissions} from "@/features/organizations/use-organization-permissions"; +import {useOrganizationPermissions} from "@/features/organizations/hooks/use-organization-permissions"; import {StorageChannel} from "@/db/schema/12_storage-channel"; import { SettingsOrganizationMembersTable -} from "@/features/organizations/organization-members-table"; +} from "@/features/organizations/components/organization-members-table"; import { OrganizationNotifiersTab -} from "@/features/organizations/organization-notifiers-tab"; +} from "@/features/organizations/components/organization-notifiers-tab"; import { OrganizationStoragesTab -} from "@/features/organizations/organization-storages-tab"; +} from "@/features/organizations/components/organization-storages-tab"; import { OrganizationAgentsTab -} from "@/features/organizations/organization-agents-tab"; +} from "@/features/organizations/components/organization-agents-tab"; import {Agent} from "@/db/schema/08_agent"; export type OrganizationTabsProps = { diff --git a/src/features/organizations/update-organization-form.tsx b/src/features/organizations/components/update-organization-form.tsx similarity index 96% rename from src/features/organizations/update-organization-form.tsx rename to src/features/organizations/components/update-organization-form.tsx index 45e3b510..85fea159 100644 --- a/src/features/organizations/update-organization-form.tsx +++ b/src/features/organizations/components/update-organization-form.tsx @@ -7,13 +7,13 @@ import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodFo import { UpdateOrganizationSchema, UpdateOrganizationSchemaType -} from "@/features/organizations/admin-organization.schema"; +} from "@/features/organizations/schemas/admin-organization.schema"; import {ButtonWithLoading} from "@/components/common/button-with-loading"; import {Input} from "@/components/ui/input"; import {authClient} from "@/lib/auth/auth-client"; import {toast} from "sonner"; import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization"; -import {updateOrganizationAction} from "@/features/organizations/organization.action"; +import {updateOrganizationAction} from "@/features/organizations/actions/organization.action"; type UpdateOrganizationFormProps = { onSuccessAction?: () => void; diff --git a/src/features/organizations/use-organization-permissions.ts b/src/features/organizations/hooks/use-organization-permissions.ts similarity index 100% rename from src/features/organizations/use-organization-permissions.ts rename to src/features/organizations/hooks/use-organization-permissions.ts diff --git a/src/features/organizations/admin-organization.schema.ts b/src/features/organizations/schemas/admin-organization.schema.ts similarity index 100% rename from src/features/organizations/admin-organization.schema.ts rename to src/features/organizations/schemas/admin-organization.schema.ts diff --git a/src/features/organizations/channels-organization.schema.ts b/src/features/organizations/schemas/channels-organization.schema.ts similarity index 100% rename from src/features/organizations/channels-organization.schema.ts rename to src/features/organizations/schemas/channels-organization.schema.ts diff --git a/src/features/organizations/member.schema.ts b/src/features/organizations/schemas/member.schema.ts similarity index 100% rename from src/features/organizations/member.schema.ts rename to src/features/organizations/schemas/member.schema.ts diff --git a/src/features/organizations/organization.schema.ts b/src/features/organizations/schemas/organization.schema.ts similarity index 77% rename from src/features/organizations/organization.schema.ts rename to src/features/organizations/schemas/organization.schema.ts index c23c4425..c3765b40 100644 --- a/src/features/organizations/organization.schema.ts +++ b/src/features/organizations/schemas/organization.schema.ts @@ -1,14 +1,14 @@ import { z } from "zod"; export const CreateOrganizationSchema = z.object({ - name: z.string().min(5, "Name must be at least 5 characters long").max(40, "Name must be at most 40 characters long"), + name: z.string().min(2, "Name must be at least 2 characters long").max(40, "Name must be at most 40 characters long"), }); export const UpdateOrganizationSchema = z.object({ - name: z.string().min(5, 'Name must be at least 5 characters long').max(40, 'Name must be at most 40 characters long'), + name: z.string().min(2, 'Name must be at least 2 characters long').max(40, 'Name must be at most 40 characters long'), slug: z.string() .regex(/^[a-zA-Z0-9_-]*$/, 'Slug can only contain letters, numbers, underscores, and hyphens') - .min(5, 'Slug must be at least 5 characters long') + .min(2, 'Slug must be at least 2 characters long') .max(20, 'Slug must be at most 20 characters long'), users: z.array(z.string()), }); diff --git a/src/features/organizations/organization-cookie.ts b/src/features/organizations/utils/organization-cookie.ts similarity index 100% rename from src/features/organizations/organization-cookie.ts rename to src/features/organizations/utils/organization-cookie.ts diff --git a/src/features/profile/actions/avatar.action.ts b/src/features/profile/actions/avatar.action.ts new file mode 100644 index 00000000..b9b84dbb --- /dev/null +++ b/src/features/profile/actions/avatar.action.ts @@ -0,0 +1,36 @@ +"use server"; +import { db } from "@/db"; +import { userAction } from "@/lib/safe-actions/actions"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import * as drizzleDb from "@/db"; + +export const updateImageUserAction = userAction + .schema(z.string()) + .action(async ({ parsedInput, ctx }) => { + console.log("parsedInput", parsedInput); + + const [updatedUser] = await db + .update(drizzleDb.schemas.user) + .set({ image: parsedInput }) + .where(eq(drizzleDb.schemas.user.id, ctx.user.id)) + .returning(); + + return { + data: updatedUser, + }; + }); + +export const resetImageUserAction = userAction + .schema(z.void()) + .action(async ({ ctx }) => { + const [updatedUser] = await db + .update(drizzleDb.schemas.user) + .set({ image: null }) + .where(eq(drizzleDb.schemas.user.id, ctx.user.id)) + .returning(); + + return { + data: updatedUser, + }; + }); diff --git a/src/features/profile/profile.action.ts b/src/features/profile/actions/profile.action.ts similarity index 100% rename from src/features/profile/profile.action.ts rename to src/features/profile/actions/profile.action.ts diff --git a/src/features/profile/provider.action.ts b/src/features/profile/actions/provider.action.ts similarity index 100% rename from src/features/profile/provider.action.ts rename to src/features/profile/actions/provider.action.ts diff --git a/src/features/profile/security.action.ts b/src/features/profile/actions/security.action.ts similarity index 100% rename from src/features/profile/security.action.ts rename to src/features/profile/actions/security.action.ts diff --git a/src/features/profile/avatar-with-upload.tsx b/src/features/profile/avatar-with-upload.tsx deleted file mode 100644 index 639abd34..00000000 --- a/src/features/profile/avatar-with-upload.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; -import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"; -import {UploadIcon} from "lucide-react"; -import {toast} from "sonner"; -import {uploadUserImageAction} from "@/features/upload/upload.action"; -import {useMutation} from "@tanstack/react-query"; -import {updateImageUserAction} from "@/features/profile/avatar.action"; -import {useRouter} from "next/navigation"; -import {User} from "@/db/schema/02_user"; -import React, {ChangeEvent} from "react"; - -export type AvatarWithUploadProps = { - user: User; -}; - -export const AvatarWithUpload = (props: AvatarWithUploadProps) => { - const user = props.user; - const router = useRouter(); - - const submitImage = useMutation({ - mutationFn: async (file: File) => { - const formData = new FormData(); - formData.set("file", file); - const result = await uploadUserImageAction(formData); - - const inner = result?.data; - - if (inner?.success) { - - const updateUser = await updateImageUserAction(inner.value ?? ""); - const dataUser = updateUser?.data; - - if (updateUser?.serverError || !dataUser) { - toast.error(updateUser?.serverError); - return; - } - - toast.success(inner.actionSuccess?.message); - router.refresh(); - } else { - toast.error(inner?.actionError?.message); - } - - - }, - }); - - const handleImageUpload = async (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - const allowedFormats = ["image/jpeg", "image/png", "image/webp"]; - - if (!allowedFormats.includes(file.type)) { - toast.error("Only JPG, PNG or WebP images are allowed."); - return; - } - - const maxSizeInMB = 5; - if (file.size > maxSizeInMB * 1024 * 1024) { - toast.error(`Image must be smaller than ${maxSizeInMB}MB.`); - return; - } - - submitImage.mutate(file); - }; - - - - return ( -
- - - - {(user.name?.charAt(0) ?? user.email?.charAt(0) ?? "?").toUpperCase()} - - -
{ - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = ".jpg,.jpeg,.png,.webp"; - fileInput.onchange = (e: Event) => - handleImageUpload(e as unknown as React.ChangeEvent); - fileInput.click(); - }} - className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32" - > - -
-
- ); -}; diff --git a/src/features/profile/avatar.action.ts b/src/features/profile/avatar.action.ts deleted file mode 100644 index d44cc2a2..00000000 --- a/src/features/profile/avatar.action.ts +++ /dev/null @@ -1,15 +0,0 @@ -"use server"; -import { db } from "@/db"; -import {userAction} from "@/lib/safe-actions/actions"; -import { eq } from "drizzle-orm"; -import { z } from "zod"; -import * as drizzleDb from "@/db"; - - -export const updateImageUserAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }) => { - const [updatedUser] = await db.update(drizzleDb.schemas.user).set({ image: parsedInput }).where(eq(drizzleDb.schemas.user.id, ctx.user.id)).returning(); - - return { - data: updatedUser, - }; -}); diff --git a/src/features/profile/2fa-form.tsx b/src/features/profile/components/2fa-form.tsx similarity index 98% rename from src/features/profile/2fa-form.tsx rename to src/features/profile/components/2fa-form.tsx index af86c4c8..fbfe7e0a 100644 --- a/src/features/profile/2fa-form.tsx +++ b/src/features/profile/components/2fa-form.tsx @@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input"; import { useMutation } from "@tanstack/react-query"; import { Smartphone, Loader2, FileKey2 } from "lucide-react"; import { toast } from "sonner"; -import { BackupCodeSchema, OtpSchema, OtpSchemaType } from "./2fa.schema"; +import { BackupCodeSchema, OtpSchema, OtpSchemaType } from "../schemas/2fa.schema"; import { authClient } from "@/lib/auth/auth-client"; import { useState } from "react"; diff --git a/src/features/profile/components/avatar-with-upload.tsx b/src/features/profile/components/avatar-with-upload.tsx new file mode 100644 index 00000000..2dc3809d --- /dev/null +++ b/src/features/profile/components/avatar-with-upload.tsx @@ -0,0 +1,149 @@ +"use client"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Info, UploadIcon, X } from "lucide-react"; +import { toast } from "sonner"; +import { uploadUserImageAction } from "@/features/upload/actions/upload.action"; +import { useMutation } from "@tanstack/react-query"; +import { + resetImageUserAction, + updateImageUserAction, +} from "@/features/profile/actions/avatar.action"; +import { useRouter } from "next/navigation"; +import { User } from "@/db/schema/02_user"; +import React, { ChangeEvent } from "react"; + +export type AvatarWithUploadProps = { + user: User; + avatarUrl?: string; +}; + +export const AvatarWithUpload = (props: AvatarWithUploadProps) => { + const user = props.user; + const hasCustomImage = !!user.image; + const src = props.avatarUrl ?? user.image ?? undefined; + const router = useRouter(); + + const submitImage = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.set("file", file); + const result = await uploadUserImageAction(formData); + + const inner = result?.data; + + if (inner?.success) { + const updateUser = await updateImageUserAction(inner.value ?? ""); + const dataUser = updateUser?.data; + + if (updateUser?.serverError || !dataUser) { + toast.error(updateUser?.serverError); + return; + } + + toast.success(inner.actionSuccess?.message); + router.refresh(); + } else { + toast.error(inner?.actionError?.message); + } + }, + }); + + const resetImage = useMutation({ + mutationFn: async () => { + const result = await resetImageUserAction(); + if (result?.serverError) throw new Error(result.serverError); + toast.success("Avatar reset"); + router.refresh(); + }, + onError: () => toast.error("Failed to reset avatar"), + }); + + const handleImageUpload = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const allowedFormats = ["image/jpeg", "image/png", "image/webp"]; + + if (!allowedFormats.includes(file.type)) { + toast.error("Only JPG, PNG or WebP images are allowed."); + return; + } + + const maxSizeInMB = 5; + if (file.size > maxSizeInMB * 1024 * 1024) { + toast.error(`Image must be smaller than ${maxSizeInMB}MB.`); + return; + } + + submitImage.mutate(file); + }; + + return ( +
+
+ + {src && } + + {( + user.name?.charAt(0) ?? + user.email?.charAt(0) ?? + "?" + ).toUpperCase()} + + + +
{ + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".jpg,.jpeg,.png,.webp"; + fileInput.onchange = (e: Event) => + handleImageUpload( + e as unknown as React.ChangeEvent, + ); + fileInput.click(); + }} + className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32" + > + +
+ + + +
+ +
+
+ + Upload a custom photo to override your Gravatar. Without one, your + Gravatar is used automatically. If neither is set, your initials are + shown. + +
+
+ + {hasCustomImage && ( + + )} +
+ ); +}; diff --git a/src/features/profile/backup-codes-list.tsx b/src/features/profile/components/backup-codes-list.tsx similarity index 100% rename from src/features/profile/backup-codes-list.tsx rename to src/features/profile/components/backup-codes-list.tsx diff --git a/src/features/profile/disable-2fa-modal.tsx b/src/features/profile/components/disable-2fa-modal.tsx similarity index 100% rename from src/features/profile/disable-2fa-modal.tsx rename to src/features/profile/components/disable-2fa-modal.tsx diff --git a/src/features/profile/profile-account.tsx b/src/features/profile/components/profile-account.tsx similarity index 99% rename from src/features/profile/profile-account.tsx rename to src/features/profile/components/profile-account.tsx index c153487e..a54c46f0 100644 --- a/src/features/profile/profile-account.tsx +++ b/src/features/profile/components/profile-account.tsx @@ -17,7 +17,7 @@ import { FormMessage, useZodForm, } from "@/components/ui/form"; -import { EmailSchema, EmailSchemaType } from "./account.schema"; +import { EmailSchema, EmailSchemaType } from "../schemas/account.schema"; import { BetterAuthError } from "@/types/auth"; import { ProfileApiKeys } from "./profile-api-keys"; diff --git a/src/features/profile/profile-api-keys.tsx b/src/features/profile/components/profile-api-keys.tsx similarity index 99% rename from src/features/profile/profile-api-keys.tsx rename to src/features/profile/components/profile-api-keys.tsx index 68e38869..71aa3a28 100644 --- a/src/features/profile/profile-api-keys.tsx +++ b/src/features/profile/components/profile-api-keys.tsx @@ -21,7 +21,7 @@ import { createApiKeysAction, deleteApiKeyAction, getApiKeysAction, -} from "@/features/profile/profile.action"; +} from "@/features/profile/actions/profile.action"; import {copyToClipboardWithMeta} from "@/components/common/copy-button"; import Link from "next/link"; diff --git a/src/features/profile/profile-appearance.tsx b/src/features/profile/components/profile-appearance.tsx similarity index 97% rename from src/features/profile/profile-appearance.tsx rename to src/features/profile/components/profile-appearance.tsx index 209beb0e..a97045b7 100644 --- a/src/features/profile/profile-appearance.tsx +++ b/src/features/profile/components/profile-appearance.tsx @@ -21,7 +21,7 @@ export function ProfileAppearance() { } function ThemeSelector() { - const {theme} = useTheme(); + const {theme, setTheme} = useTheme(); return ( @@ -39,6 +39,7 @@ function ThemeSelector() { isActive ? "border-primary bg-primary/5" : "border-muted/40" )} onClick={async () => { + setTheme(item.value); await authClient.updateUser({theme: item.value}); }} > diff --git a/src/features/profile/profile-general.tsx b/src/features/profile/components/profile-general.tsx similarity index 91% rename from src/features/profile/profile-general.tsx rename to src/features/profile/components/profile-general.tsx index 151a3169..5473bb67 100644 --- a/src/features/profile/profile-general.tsx +++ b/src/features/profile/components/profile-general.tsx @@ -18,16 +18,17 @@ import { import {useMutation} from "@tanstack/react-query"; import {toast} from "sonner"; import {useRouter} from "next/navigation"; -import {updateProfileSettingsAction} from "./profile.action"; +import {updateProfileSettingsAction} from "../actions/profile.action"; import {User} from "@/db/schema/02_user"; -import {ProfileSchema, ProfileSchemaType} from "./general.schema"; -import {AvatarWithUpload} from "@/features/profile/avatar-with-upload"; +import {ProfileSchema, ProfileSchemaType} from "../schemas/general.schema"; +import {AvatarWithUpload} from "@/features/profile/components/avatar-with-upload"; interface ProfileGeneralProps { user: User; + avatarUrl?: string; } -export function ProfileGeneral({user}: ProfileGeneralProps) { +export function ProfileGeneral({user, avatarUrl}: ProfileGeneralProps) { const router = useRouter(); const profileForm = useZodForm({ @@ -60,9 +61,10 @@ export function ProfileGeneral({user}: ProfileGeneralProps) {
-
+
diff --git a/src/features/profile/profile-providers.tsx b/src/features/profile/components/profile-providers.tsx similarity index 100% rename from src/features/profile/profile-providers.tsx rename to src/features/profile/components/profile-providers.tsx diff --git a/src/features/profile/profile-reset-password-form.tsx b/src/features/profile/components/profile-reset-password-form.tsx similarity index 99% rename from src/features/profile/profile-reset-password-form.tsx rename to src/features/profile/components/profile-reset-password-form.tsx index 75f6f6e6..2628f5eb 100644 --- a/src/features/profile/profile-reset-password-form.tsx +++ b/src/features/profile/components/profile-reset-password-form.tsx @@ -10,7 +10,7 @@ import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { authClient } from "@/lib/auth/auth-client"; -import { ResetPasswordSecuritySchema, ResetPasswordSecuritySchemaType } from "./security.schema"; +import { ResetPasswordSecuritySchema, ResetPasswordSecuritySchemaType } from "../schemas/security.schema"; import {PasswordInput} from "@/components/ui/password-input"; type ResetPasswordFormProps = { diff --git a/src/features/profile/profile-security.tsx b/src/features/profile/components/profile-security.tsx similarity index 99% rename from src/features/profile/profile-security.tsx rename to src/features/profile/components/profile-security.tsx index 4ffcb910..7a9582c3 100644 --- a/src/features/profile/profile-security.tsx +++ b/src/features/profile/components/profile-security.tsx @@ -19,7 +19,7 @@ import { revokeSessionAction, getPasskeysAction, revokePasskeyAction, -} from "./security.action"; +} from "../actions/security.action"; import { useRouter } from "next/navigation"; import { ResetPasswordProfileProviderModal } from "./reset-password-modal"; import { SetPasswordProfileProviderModal } from "./set-password-modal"; diff --git a/src/features/profile/reset-password-modal.tsx b/src/features/profile/components/reset-password-modal.tsx similarity index 100% rename from src/features/profile/reset-password-modal.tsx rename to src/features/profile/components/reset-password-modal.tsx diff --git a/src/features/profile/set-password-form.tsx b/src/features/profile/components/set-password-form.tsx similarity index 96% rename from src/features/profile/set-password-form.tsx rename to src/features/profile/components/set-password-form.tsx index e5e7a4e8..365a2b33 100644 --- a/src/features/profile/set-password-form.tsx +++ b/src/features/profile/components/set-password-form.tsx @@ -7,8 +7,8 @@ import {PasswordStrengthInput} from "@/components/ui/password-input-indicator"; import {useMutation} from "@tanstack/react-query"; import {useRouter} from "next/navigation"; import {toast} from "sonner"; -import {PasswordProviderSchema, PasswordProviderSchemaType} from "./provider.schema"; -import {linkPasswordProfileProviderAction} from "./provider.action"; +import {PasswordProviderSchema, PasswordProviderSchemaType} from "../schemas/provider.schema"; +import {linkPasswordProfileProviderAction} from "../actions/provider.action"; import {PasswordInput} from "@/components/ui/password-input"; type SetPasswordFormProps = { diff --git a/src/features/profile/set-password-modal.tsx b/src/features/profile/components/set-password-modal.tsx similarity index 100% rename from src/features/profile/set-password-modal.tsx rename to src/features/profile/components/set-password-modal.tsx diff --git a/src/features/profile/components/setup-2fa-modal.tsx b/src/features/profile/components/setup-2fa-modal.tsx new file mode 100644 index 00000000..9f37c20f --- /dev/null +++ b/src/features/profile/components/setup-2fa-modal.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { TwoFactorSetupContent } from "./two-factor-setup-content"; + +type Setup2FAModalProps = { + onOpenChange: (open: boolean) => void; + open: boolean; + disabled: boolean; +}; + +export function Setup2FAProfileProviderModal({ onOpenChange, open, disabled }: Setup2FAModalProps) { + const router = useRouter(); + + const handleSuccess = () => { + router.refresh(); + onOpenChange(false); + }; + + return ( + + + + + + + Enable Two-Factor Authentication + + + + + ); +} diff --git a/src/features/profile/components/two-factor-setup-content.tsx b/src/features/profile/components/two-factor-setup-content.tsx new file mode 100644 index 00000000..3409c5bd --- /dev/null +++ b/src/features/profile/components/two-factor-setup-content.tsx @@ -0,0 +1,171 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form"; +import { Loader2, Copy, CheckCircle2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { Setup2FASecuritySchema, Setup2FASecuritySchemaType } from "../schemas/security.schema"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth/auth-client"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; +import QRCode from "react-qr-code"; +import z from "zod"; +import { zPassword } from "@/lib/zod"; +import { BackupCodesList } from "./backup-codes-list"; +import { PasswordInput } from "@/components/ui/password-input"; + +const PasswordSchema = z.object({ password: zPassword() }); +type Password = z.infer; + +type Props = { + onSuccess: () => void; +}; + +export function TwoFactorSetupContent({ onSuccess }: Props) { + const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD"); + const [totpURI, setTotpURI] = useState(""); + const [secret, setSecret] = useState(""); + const [backupCodes, setBackupCodes] = useState([]); + + const otpForm = useZodForm({ schema: Setup2FASecuritySchema, defaultValues: { code: "" } }); + const passwordForm = useZodForm({ schema: PasswordSchema, defaultValues: { password: "" } }); + + const { mutate: enable2FA, isPending: isEnabling } = useMutation({ + mutationFn: async (values: Password) => { + const { data, error } = await authClient.twoFactor.enable({ password: values.password }); + if (error) throw error; + return data; + }, + onSuccess: (data) => { + setTotpURI(data.totpURI); + setSecret(data.totpURI.split("secret=")[1].split("&")[0]); + setBackupCodes(data.backupCodes || []); + setStep("QR"); + }, + onError: () => toast.error("Failed to enable two-factor authentication."), + }); + + const { mutate: verify2FA, isPending: isVerifying } = useMutation({ + mutationFn: async (values: Setup2FASecuritySchemaType) => { + const { data, error } = await authClient.twoFactor.verifyTotp({ code: values.code, trustDevice: true }); + if (error) throw error; + return data; + }, + onSuccess: () => { + toast.success("Two-factor authentication enabled successfully."); + setStep("BACKUP"); + }, + onError: () => { + toast.error("The provided code is invalid."); + otpForm.reset(); + }, + }); + + const handleCopySecret = () => { + navigator.clipboard.writeText(secret); + toast.success("Secret copied to clipboard"); + }; + + if (step === "PASSWORD") { + return ( +
enable2FA(values)}> +
+ ( + + Current Password + + + + + + )} + /> +
+ +
+
+
+ ); + } + + if (step === "QR") { + return ( +
verify2FA(values)}> +
+

+ Scan the QR code with your authentication app or enter the secret key manually. +

+
+ {totpURI && ( + + )} +
+
+

Secret key:

+
+ {secret} + +
+
+
+ ( + + Verification Code + + { + field.onChange(value); + if (value.length === 6) verify2FA(otpForm.getValues()); + }} + > + + {[0, 1, 2, 3, 4, 5].map((i) => )} + + + + + )} + /> +
+
+ +
+
+
+ ); + } + + return ( +
+ + + + Two Factor Authentication is now enabled on your account. + + + +
+ +
+
+ ); +} diff --git a/src/features/profile/view-backup-codes-modal.tsx b/src/features/profile/components/view-backup-codes-modal.tsx similarity index 100% rename from src/features/profile/view-backup-codes-modal.tsx rename to src/features/profile/components/view-backup-codes-modal.tsx diff --git a/src/features/profile/2fa.schema.ts b/src/features/profile/schemas/2fa.schema.ts similarity index 100% rename from src/features/profile/2fa.schema.ts rename to src/features/profile/schemas/2fa.schema.ts diff --git a/src/features/profile/account.schema.ts b/src/features/profile/schemas/account.schema.ts similarity index 100% rename from src/features/profile/account.schema.ts rename to src/features/profile/schemas/account.schema.ts diff --git a/src/features/profile/general.schema.ts b/src/features/profile/schemas/general.schema.ts similarity index 100% rename from src/features/profile/general.schema.ts rename to src/features/profile/schemas/general.schema.ts diff --git a/src/features/profile/provider.schema.ts b/src/features/profile/schemas/provider.schema.ts similarity index 100% rename from src/features/profile/provider.schema.ts rename to src/features/profile/schemas/provider.schema.ts diff --git a/src/features/profile/security.schema.ts b/src/features/profile/schemas/security.schema.ts similarity index 100% rename from src/features/profile/security.schema.ts rename to src/features/profile/schemas/security.schema.ts diff --git a/src/features/profile/setup-2fa-modal.tsx b/src/features/profile/setup-2fa-modal.tsx deleted file mode 100644 index 41712dce..00000000 --- a/src/features/profile/setup-2fa-modal.tsx +++ /dev/null @@ -1,256 +0,0 @@ -"use client"; - -import React, {useState} from "react"; -import {Button} from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger -} from "@/components/ui/dialog"; -import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; -import {Loader2, Copy, CheckCircle2, ShieldCheck} from "lucide-react"; -import {useMutation} from "@tanstack/react-query"; -import {useRouter} from "next/navigation"; -import {Setup2FASecuritySchema, Setup2FASecuritySchemaType} from "./security.schema"; -import {toast} from "sonner"; -import {authClient} from "@/lib/auth/auth-client"; -import {Alert, AlertDescription} from "@/components/ui/alert"; -import {InputOTP, InputOTPGroup, InputOTPSlot} from "@/components/ui/input-otp"; -import QRCode from "react-qr-code"; -import z from "zod"; -import {zPassword} from "@/lib/zod"; -import {BackupCodesList} from "./backup-codes-list"; -import {PasswordInput} from "@/components/ui/password-input"; -import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; - -const PasswordSchema = z.object({ - password: zPassword(), -}); - -type Password = z.infer; - -type Setup2FAModalProps = { - onOpenChange: (open: boolean) => void; - open: boolean; - disabled: boolean; -}; - -export function Setup2FAProfileProviderModal({onOpenChange, open, disabled}: Setup2FAModalProps) { - - const router = useRouter(); - const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD"); - - const [totpURI, setTotpURI] = useState(""); - const [secret, setSecret] = useState(""); - const [backupCodes, setBackupCodes] = useState([]); - - const form = useZodForm({ - schema: Setup2FASecuritySchema, - defaultValues: { - code: "", - }, - }); - - const passwordForm = useZodForm({ - schema: PasswordSchema, - defaultValues: { - password: "", - }, - }); - - const {mutate: enable2FA, isPending: isEnabling} = useMutation({ - mutationFn: async (values: Password) => { - const {data, error} = await authClient.twoFactor.enable({ - password: values.password, - }); - - if (error) throw error; - return data; - }, - onSuccess: (data) => { - setTotpURI(data.totpURI); - setSecret(data.totpURI.split("secret=")[1].split("&")[0]); - setBackupCodes(data.backupCodes || []); - setStep("QR"); - }, - onError: () => { - toast.error("Failed to enable two-factor authentication."); - }, - }); - - const {mutate: verify2FA, isPending: isVerifying} = useMutation({ - mutationFn: async (values: Setup2FASecuritySchemaType) => { - const {data, error} = await authClient.twoFactor.verifyTotp({ - code: values.code, - trustDevice: true, - }); - - if (error) throw error; - return data; - }, - onSuccess: () => { - toast.success("Two-factor authentication enabled successfully."); - setStep("BACKUP"); - }, - onError: () => { - toast.error("The provided code is invalid."); - form.reset(); - }, - }); - - const handleCopySecret = () => { - navigator.clipboard.writeText(secret); - toast.success("Secret copied to clipboard"); - }; - - const handleClose = () => { - router.refresh(); - onOpenChange(false); - setStep("PASSWORD"); - form.reset(); - passwordForm.reset(); - }; - - - return ( - (!v ? handleClose() : onOpenChange(v))}> - - - - - - Enable Two-Factor Authentication - - {step === "PASSWORD" && ""} - {step === "QR" && "Scan the QR code below with your authentication app or enter the secret key manually."} - {step === "BACKUP" && "Save these backup codes in a secure location. They can be used to access your account if you lose access to your authentication device."} - - - - {step === "PASSWORD" && ( -
enable2FA(values)}> -
-
- ( - - Current Password - - - - - - )} - /> -
-
- -
-
-
- )} - - {step === "QR" && ( -
{ - verify2FA(values); - }} - > -
-
- {totpURI && ( - - )} -
- -
-

If you are unable to scan the - QR code, you can manually enter the secret key into your authentication app :

-
- {secret} - -
-
- -
- ( - - Verification Code - - { - field.onChange(value); - if (value.length === 6) { - verify2FA(form.getValues()); - } - }} - > - - - - - - - - - - - - )} - /> -
- -
- -
-
-
- )} - - {step === "BACKUP" && ( -
- - - Two Factor Authentication is now enabled - on your account. - - - - -
- -
-
- )} -
-
- ); -} diff --git a/src/features/projects/project-delete.action.ts b/src/features/projects/actions/project-delete.action.ts similarity index 100% rename from src/features/projects/project-delete.action.ts rename to src/features/projects/actions/project-delete.action.ts diff --git a/src/features/projects/projects.action.ts b/src/features/projects/actions/projects.action.ts similarity index 98% rename from src/features/projects/projects.action.ts rename to src/features/projects/actions/projects.action.ts index 24097443..288f4bec 100644 --- a/src/features/projects/projects.action.ts +++ b/src/features/projects/actions/projects.action.ts @@ -1,7 +1,7 @@ "use server"; import {userAction} from "@/lib/safe-actions/actions"; -import {ProjectSchema} from "@/features/projects/projects.schema"; +import {ProjectSchema} from "@/features/projects/schemas/projects.schema"; import {z} from "zod"; import {ServerActionResult} from "@/types/action-type"; import {db} from "@/db"; diff --git a/src/features/projects/project-card.tsx b/src/features/projects/components/project-card.tsx similarity index 100% rename from src/features/projects/project-card.tsx rename to src/features/projects/components/project-card.tsx diff --git a/src/features/projects/project-database-card.tsx b/src/features/projects/components/project-database-card.tsx similarity index 100% rename from src/features/projects/project-database-card.tsx rename to src/features/projects/components/project-database-card.tsx diff --git a/src/features/projects/project-delete-button.tsx b/src/features/projects/components/project-delete-button.tsx similarity index 97% rename from src/features/projects/project-delete-button.tsx rename to src/features/projects/components/project-delete-button.tsx index e3babc8f..cfe9307e 100644 --- a/src/features/projects/project-delete-button.tsx +++ b/src/features/projects/components/project-delete-button.tsx @@ -5,7 +5,7 @@ import {ButtonWithConfirm} from "@/components/common/button-with-confirm"; import {useMutation} from "@tanstack/react-query"; import { deleteProjectAction -} from "@/features/projects/project-delete.action"; +} from "@/features/projects/actions/project-delete.action"; import {useRouter} from "next/navigation"; import {toast} from "sonner"; import {useIsMobile} from "@/hooks/use-mobile"; diff --git a/src/features/projects/project-dialog.tsx b/src/features/projects/components/project-dialog.tsx similarity index 97% rename from src/features/projects/project-dialog.tsx rename to src/features/projects/components/project-dialog.tsx index 6aed599b..adf53fab 100644 --- a/src/features/projects/project-dialog.tsx +++ b/src/features/projects/components/project-dialog.tsx @@ -7,7 +7,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import {ProjectForm} from "@/features/projects/project-form"; +import {ProjectForm} from "@/features/projects/components/project-form"; import {ReactNode, useState} from "react"; import {Button, buttonVariants} from "@/components/ui/button"; import {Plus} from "lucide-react"; diff --git a/src/features/projects/project-form.tsx b/src/features/projects/components/project-form.tsx similarity index 97% rename from src/features/projects/project-form.tsx rename to src/features/projects/components/project-form.tsx index cc2496a4..10ac95c8 100644 --- a/src/features/projects/project-form.tsx +++ b/src/features/projects/components/project-form.tsx @@ -5,8 +5,8 @@ import { Input } from "@/components/ui/input"; import { Form } from "@/components/ui/form"; import { Button } from "@/components/ui/button"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { ProjectSchema, ProjectType } from "@/features/projects/projects.schema"; -import { createProjectAction, updateProjectAction } from "@/features/projects/projects.action"; +import { ProjectSchema, ProjectType } from "@/features/projects/schemas/projects.schema"; +import { createProjectAction, updateProjectAction } from "@/features/projects/actions/projects.action"; import { useRouter } from "next/navigation"; import { MultiSelect } from "@/components/common/multi-select"; import { toast } from "sonner"; @@ -123,6 +123,7 @@ export const ProjectForm = (props: projectFormProps) => { placeholder="Select databases" variant="inverted" animation={2} + modalPopover={true} /> Select databases you want to add to this project diff --git a/src/features/projects/projects.schema.ts b/src/features/projects/schemas/projects.schema.ts similarity index 100% rename from src/features/projects/projects.schema.ts rename to src/features/projects/schemas/projects.schema.ts diff --git a/src/features/settings/email-form.action.ts b/src/features/settings/actions/email-form.action.ts similarity index 90% rename from src/features/settings/email-form.action.ts rename to src/features/settings/actions/email-form.action.ts index 2fdf65ce..a0983e28 100644 --- a/src/features/settings/email-form.action.ts +++ b/src/features/settings/actions/email-form.action.ts @@ -1,6 +1,6 @@ "use server"; import { z } from "zod"; -import { EmailFormSchema } from "@/features/settings/email-form.schema"; +import { EmailFormSchema } from "@/features/settings/schemas/email-form.schema"; import { eq } from "drizzle-orm"; import { db } from "@/db"; import * as drizzleDb from "@/db"; diff --git a/src/features/settings/notification.action.ts b/src/features/settings/actions/notification.action.ts similarity index 94% rename from src/features/settings/notification.action.ts rename to src/features/settings/actions/notification.action.ts index 2ec3b427..2a1f0a6d 100644 --- a/src/features/settings/notification.action.ts +++ b/src/features/settings/actions/notification.action.ts @@ -7,7 +7,7 @@ import {eq} from "drizzle-orm"; import {ServerActionResult} from "@/types/action-type"; import {Setting} from "@/db/schema/01_setting"; import {z} from "zod"; -import {DefaultNotificationSchema} from "@/features/settings/notification.schema"; +import {DefaultNotificationSchema} from "@/features/settings/schemas/notification.schema"; import {withUpdatedAt} from "@/db/utils"; export const updateNotificationSettingsAction = userAction diff --git a/src/features/settings/storage-s3.action.ts b/src/features/settings/actions/storage-s3.action.ts similarity index 97% rename from src/features/settings/storage-s3.action.ts rename to src/features/settings/actions/storage-s3.action.ts index fde17585..fb1dae98 100644 --- a/src/features/settings/storage-s3.action.ts +++ b/src/features/settings/actions/storage-s3.action.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { db } from "@/db"; -import { S3FormSchema, StorageSwitchSchema } from "@/features/settings/storage-s3.schema"; +import { S3FormSchema, StorageSwitchSchema } from "@/features/settings/schemas/storage-s3.schema"; import { eq } from "drizzle-orm"; import * as drizzleDb from "@/db"; import {userAction} from "@/lib/safe-actions/actions"; diff --git a/src/features/settings/storage.action.ts b/src/features/settings/actions/storage.action.ts similarity index 95% rename from src/features/settings/storage.action.ts rename to src/features/settings/actions/storage.action.ts index d9e44c91..721b0a94 100644 --- a/src/features/settings/storage.action.ts +++ b/src/features/settings/actions/storage.action.ts @@ -7,7 +7,7 @@ import {eq} from "drizzle-orm"; import {ServerActionResult} from "@/types/action-type"; import {Setting} from "@/db/schema/01_setting"; import {z} from "zod"; -import {DefaultStorageSchema} from "@/features/settings/storage.schema"; +import {DefaultStorageSchema} from "@/features/settings/schemas/storage.schema"; import {withUpdatedAt} from "@/db/utils"; export const updateStorageSettingsAction = userAction diff --git a/src/features/settings/email-form.tsx b/src/features/settings/components/email-form.tsx similarity index 95% rename from src/features/settings/email-form.tsx rename to src/features/settings/components/email-form.tsx index 13abef64..b91c2380 100644 --- a/src/features/settings/email-form.tsx +++ b/src/features/settings/components/email-form.tsx @@ -18,11 +18,11 @@ import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/compon import { EmailFormSchema, EmailFormType -} from "@/features/settings/email-form.schema"; +} from "@/features/settings/schemas/email-form.schema"; import {PasswordInput} from "@/components/ui/password-input"; import { updateEmailSettingsAction -} from "@/features/settings/email-form.action"; +} from "@/features/settings/actions/email-form.action"; import {toast} from "sonner"; import {useRouter} from "next/navigation"; import {sendEmail} from "@/lib/email"; @@ -54,7 +54,14 @@ export const EmailForm = (props: EmailFormProps) => { return; } toast.success(`Success updating email informations`); - form.reset(data); + form.reset({ + smtpPassword: data.smtpPassword ?? undefined, + smtpFrom: data.smtpFrom ?? undefined, + smtpHost: data.smtpHost ?? undefined, + smtpPort: data.smtpPort ?? undefined, + smtpUser: data.smtpUser ?? undefined, + smtpSecure: data.smtpSecure ?? undefined, + }); router.refresh(); }, }); diff --git a/src/features/settings/email-section.tsx b/src/features/settings/components/email-section.tsx similarity index 74% rename from src/features/settings/email-section.tsx rename to src/features/settings/components/email-section.tsx index 05ddfcf3..650ed4a5 100644 --- a/src/features/settings/email-section.tsx +++ b/src/features/settings/components/email-section.tsx @@ -1,7 +1,7 @@ "use client" -import {EmailForm} from "@/features/settings/email-form"; +import {EmailForm} from "@/features/settings/components/email-form"; import {Setting} from "@/db/schema/01_setting"; -import {EmailFormType} from "@/features/settings/email-form.schema"; +import {EmailFormType} from "@/features/settings/schemas/email-form.schema"; export type SettingsEmailSectionProps = { diff --git a/src/features/settings/notification-section.tsx b/src/features/settings/components/notification-section.tsx similarity index 97% rename from src/features/settings/notification-section.tsx rename to src/features/settings/components/notification-section.tsx index d4a14650..06505110 100644 --- a/src/features/settings/notification-section.tsx +++ b/src/features/settings/components/notification-section.tsx @@ -13,14 +13,14 @@ import { } from "@/components/ui/form"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; import {useMutation} from "@tanstack/react-query"; -import {getChannelIcon} from "@/features/channel/channels-helpers"; +import {getChannelIcon} from "@/features/channel/components/channels-helpers"; import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; import { DefaultNotificationSchema, DefaultNotificationType -} from "@/features/settings/notification.schema"; +} from "@/features/settings/schemas/notification.schema"; import { updateNotificationSettingsAction -} from "@/features/settings/notification.action"; +} from "@/features/settings/actions/notification.action"; import {toast} from "sonner"; import {Badge} from "@/components/ui/badge"; diff --git a/src/features/settings/components/settings-tabs.tsx b/src/features/settings/components/settings-tabs.tsx new file mode 100644 index 00000000..313c4ee4 --- /dev/null +++ b/src/features/settings/components/settings-tabs.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Setting } from "@/db/schema/01_setting"; +import { SettingsEmailSection } from "@/features/settings/components/email-section"; +import { SettingsStorageSection } from "@/features/settings/components/storage-section"; +import { StorageChannelWith } from "@/db/schema/12_storage-channel"; +import { AlarmClock, MailboxIcon, Save } from "lucide-react"; +import { SettingsNotificationSection } from "@/features/settings/components/notification-section"; +import { NotificationChannelWith } from "@/db/schema/09_notification-channel"; + +export type SettingsTabsProps = { + settings: Setting; + storageChannels: StorageChannelWith[]; + notificationChannels: NotificationChannelWith[]; +}; + +export const SettingsTabs = ({ settings, storageChannels, notificationChannels }: SettingsTabsProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [tab, setTab] = useState(() => searchParams.get("tab") ?? "email"); + + useEffect(() => { + const newTab = searchParams.get("tab") ?? "email"; + setTab(newTab); + }, [searchParams]); + + const handleChangeTab = (value: string) => { + router.push(`?tab=${value}`); + }; + + const tabs = [ + { + name: "System Email", + value: "email", + icon: MailboxIcon, + content: , + }, + { + name: "Storage", + value: "storage", + icon: Save, + content: , + }, + { + name: "Notification", + value: "notification", + icon: AlarmClock, + content: , + }, + ]; + + return ( +
+ + + {tabs.map(({ icon: Icon, name, value }) => ( + + + {name} + + ))} + + + {tabs.map((t) => ( + + {t.content} + + ))} + +
+ ); +}; diff --git a/src/features/settings/storage-s3-form.tsx b/src/features/settings/components/storage-s3-form.tsx similarity index 96% rename from src/features/settings/storage-s3-form.tsx rename to src/features/settings/components/storage-s3-form.tsx index bb24dea3..0e3ed1fa 100644 --- a/src/features/settings/storage-s3-form.tsx +++ b/src/features/settings/components/storage-s3-form.tsx @@ -8,10 +8,10 @@ import { Button } from "@/components/ui/button"; import { useMutation } from "@tanstack/react-query"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { S3FormSchema, S3FormType } from "@/features/settings/storage-s3.schema"; +import { S3FormSchema, S3FormType } from "@/features/settings/schemas/storage-s3.schema"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { updateS3SettingsAction } from "@/features/settings/storage-s3.action"; +import { updateS3SettingsAction } from "@/features/settings/actions/storage-s3.action"; import {PasswordInput} from "@/components/ui/password-input"; export type S3FormProps = { diff --git a/src/features/settings/storage-section.tsx b/src/features/settings/components/storage-section.tsx similarity index 96% rename from src/features/settings/storage-section.tsx rename to src/features/settings/components/storage-section.tsx index b8c39718..b7c2c0a0 100644 --- a/src/features/settings/storage-section.tsx +++ b/src/features/settings/components/storage-section.tsx @@ -19,14 +19,14 @@ import {useMutation} from "@tanstack/react-query"; import { DefaultStorageSchema, DefaultStorageType -} from "@/features/settings/storage.schema"; -import {getChannelIcon} from "@/features/channel/channels-helpers"; +} from "@/features/settings/schemas/storage.schema"; +import {getChannelIcon} from "@/features/channel/components/channels-helpers"; import { updateStorageSettingsAction -} from "@/features/settings/storage.action"; +} from "@/features/settings/actions/storage.action"; import {toast} from "sonner"; import {Switch} from "@/components/ui/switch"; -import {downloadMasterKeyAction} from "@/features/agents/keys.action"; +import {downloadMasterKeyAction} from "@/features/agents/actions/keys.action"; export type SettingsStorageSectionProps = { settings: Setting; diff --git a/src/features/settings/email-form.schema.ts b/src/features/settings/schemas/email-form.schema.ts similarity index 100% rename from src/features/settings/email-form.schema.ts rename to src/features/settings/schemas/email-form.schema.ts diff --git a/src/features/settings/notification.schema.ts b/src/features/settings/schemas/notification.schema.ts similarity index 100% rename from src/features/settings/notification.schema.ts rename to src/features/settings/schemas/notification.schema.ts diff --git a/src/features/settings/storage-s3.schema.ts b/src/features/settings/schemas/storage-s3.schema.ts similarity index 100% rename from src/features/settings/storage-s3.schema.ts rename to src/features/settings/schemas/storage-s3.schema.ts diff --git a/src/features/settings/storage.schema.ts b/src/features/settings/schemas/storage.schema.ts similarity index 100% rename from src/features/settings/storage.schema.ts rename to src/features/settings/schemas/storage.schema.ts diff --git a/src/features/settings/settings-tabs.tsx b/src/features/settings/settings-tabs.tsx deleted file mode 100644 index e98e6cf8..00000000 --- a/src/features/settings/settings-tabs.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; -import {useEffect, useState} from "react"; -import {useRouter, useSearchParams} from "next/navigation"; -import {Setting} from "@/db/schema/01_setting"; -import {SettingsEmailSection} from "@/features/settings/email-section"; -import {SettingsStorageSection} from "@/features/settings/storage-section"; -import {StorageChannelWith} from "@/db/schema/12_storage-channel"; -import {AlarmClock, MailboxIcon, Save} from "lucide-react"; -import { - SettingsNotificationSection -} from "@/features/settings/notification-section"; -import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; - -export type SettingsTabsProps = { - settings: Setting - storageChannels: StorageChannelWith[], - notificationChannels: NotificationChannelWith[]; - -}; - -export const SettingsTabs = ({settings, storageChannels, notificationChannels}: SettingsTabsProps) => { - const router = useRouter(); - const searchParams = useSearchParams(); - - const [tab, setTab] = useState(() => searchParams.get("tab") ?? "email"); - - - useEffect(() => { - const newTab = searchParams.get("tab") ?? "email"; - setTab(newTab); - }, [searchParams]); - - const handleChangeTab = (value: string) => { - router.push(`?tab=${value}`); - }; - - - const tabs = [ - { - name: 'System Email', - value: 'email', - icon: MailboxIcon, - content: ( - - ) - }, - { - name: 'Storage', - value: 'storage', - icon: Save, - content: ( - - - ) - }, - { - name: 'Notification', - value: 'notification', - icon: AlarmClock, - content: ( - - - ) - } - ] - - - return ( -
- - - {tabs.map(({icon: Icon, name, value}) => ( - - - {name} - - ))} - - - {tabs.map(tab => ( - - {tab.content} - - ))} - -
- - - ); -}; diff --git a/src/features/statistics/chart-placeholder.tsx b/src/features/statistics/components/chart-placeholder.tsx similarity index 100% rename from src/features/statistics/chart-placeholder.tsx rename to src/features/statistics/components/chart-placeholder.tsx diff --git a/src/features/statistics/evolution-line-chart.tsx b/src/features/statistics/components/evolution-line-chart.tsx similarity index 94% rename from src/features/statistics/evolution-line-chart.tsx rename to src/features/statistics/components/evolution-line-chart.tsx index b0ce3899..45c4c9b2 100644 --- a/src/features/statistics/evolution-line-chart.tsx +++ b/src/features/statistics/components/evolution-line-chart.tsx @@ -3,8 +3,8 @@ import {ChartConfig, ChartTooltip} from "@/components/ui/chart"; import {CartesianGrid, TooltipProps, XAxis, YAxis} from "recharts"; import {formatDayOnly} from "@/utils/date-formatting"; -import {LineChartCustom} from "@/features/statistics/line-chart"; -import {generateFakeEvolutionData} from "@/features/statistics/fake-data"; +import {LineChartCustom} from "@/features/statistics/components/line-chart"; +import {generateFakeEvolutionData} from "@/features/statistics/utils/fake-data"; type Data = { createdAt: Date; diff --git a/src/features/statistics/line-chart.tsx b/src/features/statistics/components/line-chart.tsx similarity index 97% rename from src/features/statistics/line-chart.tsx rename to src/features/statistics/components/line-chart.tsx index 757f0eec..94b44f99 100644 --- a/src/features/statistics/line-chart.tsx +++ b/src/features/statistics/components/line-chart.tsx @@ -2,7 +2,7 @@ import {ChartConfig, ChartContainer} from "@/components/ui/chart"; import {Line, LineChart} from "recharts"; import {ReactNode, useState} from "react"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; -import {PlaceholderChart} from "@/features/statistics/chart-placeholder"; +import {PlaceholderChart} from "@/features/statistics/components/chart-placeholder"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; type LineChartCustomProps = { diff --git a/src/features/statistics/percentage-line-chart.tsx b/src/features/statistics/components/percentage-line-chart.tsx similarity index 97% rename from src/features/statistics/percentage-line-chart.tsx rename to src/features/statistics/components/percentage-line-chart.tsx index f2e21442..a037b87f 100644 --- a/src/features/statistics/percentage-line-chart.tsx +++ b/src/features/statistics/components/percentage-line-chart.tsx @@ -2,7 +2,7 @@ import {ChartConfig, ChartTooltip} from "@/components/ui/chart"; import {EStatusSchema} from "@/db/schema/types"; -import {LineChartCustom} from "@/features/statistics/line-chart"; +import {LineChartCustom} from "@/features/statistics/components/line-chart"; import {CartesianGrid, TooltipProps, XAxis, YAxis} from "recharts"; import {formatDayOnly} from "@/utils/date-formatting"; diff --git a/src/features/statistics/fake-data.ts b/src/features/statistics/utils/fake-data.ts similarity index 100% rename from src/features/statistics/fake-data.ts rename to src/features/statistics/utils/fake-data.ts diff --git a/src/features/storages/storages.types.ts b/src/features/storages/types/index.ts similarity index 100% rename from src/features/storages/storages.types.ts rename to src/features/storages/types/index.ts diff --git a/src/features/storages/storages.dispatch.ts b/src/features/storages/utils/storages.dispatch.ts similarity index 95% rename from src/features/storages/storages.dispatch.ts rename to src/features/storages/utils/storages.dispatch.ts index a64d98eb..fb4535e3 100644 --- a/src/features/storages/storages.dispatch.ts +++ b/src/features/storages/utils/storages.dispatch.ts @@ -5,12 +5,12 @@ import {Json} from "drizzle-zod"; import * as drizzleDb from '@/db'; import {db} from '@/db'; -import type {StorageInput, StorageProviderKind, StorageResult,} from '@/features/storages/storages.types'; -import {dispatchViaProvider} from "@/features/channel/storages"; +import type {StorageInput, StorageProviderKind, StorageResult,} from '@/features/storages/types'; +import {dispatchViaProvider} from "@/features/channel/components/storages"; import {StorageChannel} from "@/db/schema/12_storage-channel"; import { StorageChannelFormType -} from "@/features/channel/channel-form.schema"; +} from "@/features/channel/schemas/channel-form.schema"; import { logger } from "@/lib/logger"; const log = logger.child({ module: "features/storages/dispatch" }); diff --git a/src/features/storages/storages.helpers.ts b/src/features/storages/utils/storages.helpers.ts similarity index 97% rename from src/features/storages/storages.helpers.ts rename to src/features/storages/utils/storages.helpers.ts index cb4fbe82..ec05586f 100644 --- a/src/features/storages/storages.helpers.ts +++ b/src/features/storages/utils/storages.helpers.ts @@ -1,12 +1,12 @@ import {Backup, DatabaseWith} from "@/db/schema/07_database"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; +import {dispatchStorage} from "@/features/storages/utils/storages.dispatch"; import type { StorageGetInput, StorageInput, StorageMetaData, StorageResult, StorageUploadInput -} from "@/features/storages/storages.types"; +} from "@/features/storages/types"; import * as drizzleDb from "@/db"; import {withUpdatedAt} from "@/db/utils"; import {eq} from "drizzle-orm"; diff --git a/src/features/theme/mode-toggle.tsx b/src/features/theme/components/mode-toggle.tsx similarity index 100% rename from src/features/theme/mode-toggle.tsx rename to src/features/theme/components/mode-toggle.tsx diff --git a/src/features/theme/theme-meta-updater-root.tsx b/src/features/theme/components/theme-meta-updater-root.tsx similarity index 100% rename from src/features/theme/theme-meta-updater-root.tsx rename to src/features/theme/components/theme-meta-updater-root.tsx diff --git a/src/features/theme/theme-meta-updater.tsx b/src/features/theme/components/theme-meta-updater.tsx similarity index 88% rename from src/features/theme/theme-meta-updater.tsx rename to src/features/theme/components/theme-meta-updater.tsx index f7ef3a62..258ac07a 100644 --- a/src/features/theme/theme-meta-updater.tsx +++ b/src/features/theme/components/theme-meta-updater.tsx @@ -15,7 +15,10 @@ export function ThemeMetaUpdater() { useEffect(() => { if (isPending || !session?.user?.theme) return; - setTheme(session.user.theme); + const stored = localStorage.getItem("theme"); + if (!stored) { + setTheme(session.user.theme); + } }, [session, isPending, setTheme]); useEffect(() => { diff --git a/src/features/theme/theme-provider.tsx b/src/features/theme/components/theme-provider.tsx similarity index 100% rename from src/features/theme/theme-provider.tsx rename to src/features/theme/components/theme-provider.tsx diff --git a/src/features/updates/update-notification.tsx b/src/features/updates/components/update-notification.tsx similarity index 98% rename from src/features/updates/update-notification.tsx rename to src/features/updates/components/update-notification.tsx index 0b1886de..3b46b758 100644 --- a/src/features/updates/update-notification.tsx +++ b/src/features/updates/components/update-notification.tsx @@ -1,6 +1,6 @@ "use client"; -import {useUpdateCheck} from "./use-update-check"; +import {useUpdateCheck} from "../hooks/use-update-check"; import {useSidebar, SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuItem} from "@/components/ui/sidebar"; import {X, ArrowUpCircle, MoveRight} from "lucide-react"; import Link from "next/link"; diff --git a/src/features/updates/use-update-check.ts b/src/features/updates/hooks/use-update-check.ts similarity index 100% rename from src/features/updates/use-update-check.ts rename to src/features/updates/hooks/use-update-check.ts diff --git a/src/features/upload/actions/upload.action.ts b/src/features/upload/actions/upload.action.ts new file mode 100644 index 00000000..caaa13c1 --- /dev/null +++ b/src/features/upload/actions/upload.action.ts @@ -0,0 +1,105 @@ +"use server"; +import { userAction } from "@/lib/safe-actions/actions"; +import { logger } from "@/lib/logger"; +import { z } from "zod"; +import { v4 as uuidv4 } from "uuid"; +import { db } from "@/db"; +import { eq } from "drizzle-orm"; +import * as drizzleDb from "@/db"; +import { ServerActionResult } from "@/types/action-type"; +import { dispatchStorage } from "@/features/storages/utils/storages.dispatch"; +import { StorageInput } from "@/features/storages/types"; +import sharp from "sharp"; + +const log = logger.child({ module: "features/upload/upload.action" }); + +export const uploadUserImageAction = userAction + .schema(z.instanceof(FormData)) + .action( + async ({ parsedInput: formData }): Promise> => { + try { + const file = formData.get("file") as File; + const uuid = uuidv4(); + const fileFormat = file.name.split(".").pop(); + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const isPng = fileFormat === "png"; + const isWebp = fileFormat === "webp"; + + const compressedBuffer = await sharp(buffer) + .resize({ width: 1024 }) + .toFormat(isPng ? "png" : isWebp ? "webp" : "jpeg", { + quality: 80, + compressionLevel: isPng ? 9 : undefined, + }) + .toBuffer(); + + const settings = await db.query.setting.findFirst({ + where: eq(drizzleDb.schemas.setting.name, "system"), + with: { + storageChannel: true, + }, + }); + + if (!settings || !settings.storageChannel) { + return { + success: false, + actionError: { + message: "An error occurred with default settings.", + status: 500, + }, + }; + } + + const path = `images/${uuid}.${fileFormat?.toLowerCase()}`; + + const input: StorageInput = { + action: "upload", + data: { + path: path, + file: compressedBuffer, + url: true, + }, + metadata: { + storageId: settings.storageChannel.id, + fileKind: "images", + }, + }; + + const result = await dispatchStorage( + input, + undefined, + settings.storageChannel.id, + ); + log.debug({ result }, "Upload dispatch result"); + if (!result.success) { + return { + success: false, + actionError: { + message: "Failed to upload user avatar.", + status: 500, + cause: result.error, + }, + }; + } + + return { + success: true, + value: result.url, + actionSuccess: { + message: "Avatar successfully updated", + }, + }; + } catch (error) { + return { + success: false, + actionError: { + message: "Failed to upload user avatar.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + }, + ); diff --git a/src/features/upload/upload.action.ts b/src/features/upload/upload.action.ts deleted file mode 100644 index 1cf46d4f..00000000 --- a/src/features/upload/upload.action.ts +++ /dev/null @@ -1,104 +0,0 @@ -"use server"; -import {userAction} from "@/lib/safe-actions/actions"; -import {logger} from "@/lib/logger"; -import {z} from "zod"; -import {v4 as uuidv4} from "uuid"; -import {db} from "@/db"; -import {eq} from "drizzle-orm"; -import * as drizzleDb from "@/db"; -import {ServerActionResult} from "@/types/action-type"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; -import {StorageInput} from "@/features/storages/storages.types"; -import sharp from "sharp"; - -const log = logger.child({module: "features/upload/upload.action"}); - -export const uploadUserImageAction = userAction.schema( - z.instanceof(FormData) -).action(async ({parsedInput: formData}): Promise> => { - try { - - const file = formData.get("file") as File; - const uuid = uuidv4(); - const fileFormat = file.name.split(".").pop(); - const arrayBuffer = await file.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - - const isPng = fileFormat === "png"; - const isWebp = fileFormat === "webp"; - - const compressedBuffer = await sharp(buffer) - .resize({width: 1024}) - .toFormat(isPng ? "png" : isWebp ? "webp" : "jpeg", { - quality: 80, - compressionLevel: isPng ? 9 : undefined - }) - .toBuffer(); - - - const settings = await db.query.setting.findFirst({ - where: eq(drizzleDb.schemas.setting.name, "system"), - with: { - storageChannel: true - } - }); - - if (!settings || !settings.storageChannel) { - return { - success: false, - actionError: { - message: "An error occurred with default settings.", - status: 500, - }, - }; - } - - const path = `images/${uuid}.${fileFormat?.toLowerCase()}`; - - const input: StorageInput = { - action: "upload", - data: { - path: path, - file: compressedBuffer, - url: true - }, - metadata: { - storageId: settings.storageChannel.id, - fileKind: "images" - } - } - - const result = await dispatchStorage(input, undefined, settings.storageChannel.id); - log.debug({result}, "Upload dispatch result"); - if (!result.success) { - return { - success: false, - actionError: { - message: "Failed to upload user avatar.", - status: 500, - cause: result.error - }, - }; - } - - return { - success: true, - value: result.url, - actionSuccess: { - message: "Avatar successfully updated", - }, - }; - } catch (error) { - return { - success: false, - actionError: { - message: "Failed to upload user avatar.", - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - }, - }; - } -}); - - diff --git a/src/features/users/user.action.ts b/src/features/users/actions/user.action.ts similarity index 98% rename from src/features/users/user.action.ts rename to src/features/users/actions/user.action.ts index bb16eec1..dbef6e9c 100644 --- a/src/features/users/user.action.ts +++ b/src/features/users/actions/user.action.ts @@ -3,7 +3,7 @@ import * as drizzleDb from "@/db"; import { ServerActionResult } from "@/types/action-type"; import { render } from "@react-email/render"; -import { UserSchema } from "@/features/users/user.schema"; +import { UserSchema } from "@/features/users/schemas/user.schema"; import { extractNameFromEmail } from "@/utils/name-from-email"; import { generateValidPassword } from "@/utils/password"; import { auth } from "@/lib/auth/auth"; @@ -15,7 +15,7 @@ import { and, eq } from "drizzle-orm"; import { zEmail, zString } from "@/lib/zod"; import { withUpdatedAt } from "@/db/utils"; import { userAction } from "@/lib/safe-actions/actions"; -import { addMemberOrganizationAction } from "@/features/organizations/add-member.action"; +import { addMemberOrganizationAction } from "@/features/organizations/actions/add-member.action"; import { sendEmail } from "@/lib/email"; import EmailCreateUser from "@/components/emails/email-create-user"; import { SignUpUser } from "@/types/auth"; diff --git a/src/features/users/admin-user-list.tsx b/src/features/users/admin-user-list.tsx deleted file mode 100644 index a8f03dce..00000000 --- a/src/features/users/admin-user-list.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; -import { DataTable } from "@/components/common/data-table"; -import {usersListColumns} from "@/features/users/user-columns"; -import {User} from "@/db/schema/02_user"; - -type AdminUserListProps = { - users: User[]; - isPasswordAuthEnabled: boolean; -}; - -export const AdminUserList = ({ users, isPasswordAuthEnabled }: AdminUserListProps) => { - return ; -}; diff --git a/src/features/users/admin-user-add-modal.tsx b/src/features/users/components/admin-user-add-modal.tsx similarity index 94% rename from src/features/users/admin-user-add-modal.tsx rename to src/features/users/components/admin-user-add-modal.tsx index 60fd9acc..10a7b326 100644 --- a/src/features/users/admin-user-add-modal.tsx +++ b/src/features/users/components/admin-user-add-modal.tsx @@ -6,7 +6,7 @@ import { Plus } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Organization } from "@/db/schema/03_organization"; -import {AdminUserForm} from "@/features/users/admin-user-form"; +import {AdminUserForm} from "@/features/users/components/admin-user-form"; type AdminUserAddModalProps = { organizations: Organization[]; diff --git a/src/features/users/admin-user-change-password-modal.tsx b/src/features/users/components/admin-user-change-password-modal.tsx similarity index 100% rename from src/features/users/admin-user-change-password-modal.tsx rename to src/features/users/components/admin-user-change-password-modal.tsx diff --git a/src/features/users/admin-user-change-role-modal.tsx b/src/features/users/components/admin-user-change-role-modal.tsx similarity index 100% rename from src/features/users/admin-user-change-role-modal.tsx rename to src/features/users/components/admin-user-change-role-modal.tsx diff --git a/src/features/users/admin-user-delete-modal.tsx b/src/features/users/components/admin-user-delete-modal.tsx similarity index 98% rename from src/features/users/admin-user-delete-modal.tsx rename to src/features/users/components/admin-user-delete-modal.tsx index 791b294b..43d1a0d8 100644 --- a/src/features/users/admin-user-delete-modal.tsx +++ b/src/features/users/components/admin-user-delete-modal.tsx @@ -15,7 +15,7 @@ import { } from "@/components/ui/alert-dialog"; import { authClient } from "@/lib/auth/auth-client"; import { toast } from "sonner"; -import { setSuperAdminOwnerOfOrganizationsOwnedByUser } from "@/features/users/user.action"; +import { setSuperAdminOwnerOfOrganizationsOwnedByUser } from "@/features/users/actions/user.action"; type AdminDeleteUserModalProps = { open: boolean; diff --git a/src/features/users/admin-user-edit-form.tsx b/src/features/users/components/admin-user-edit-form.tsx similarity index 96% rename from src/features/users/admin-user-edit-form.tsx rename to src/features/users/components/admin-user-edit-form.tsx index 0d7ad676..ede4ff1d 100644 --- a/src/features/users/admin-user-edit-form.tsx +++ b/src/features/users/components/admin-user-edit-form.tsx @@ -7,8 +7,8 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodF import { Input } from "@/components/ui/input"; import { ButtonWithLoading } from "@/components/common/button-with-loading"; import {Button} from "@/components/ui/button"; -import {UserEditSchema, UserEditType, UserSchema} from "@/features/users/user.schema"; -import {updateUserAction} from "@/features/users/user.action"; +import {UserEditSchema, UserEditType, UserSchema} from "@/features/users/schemas/user.schema"; +import {updateUserAction} from "@/features/users/actions/user.action"; type AdminUserEditFormProps = { onSuccess?: () => void; defaultValues: { diff --git a/src/features/users/admin-user-edit-modal.tsx b/src/features/users/components/admin-user-edit-modal.tsx similarity index 93% rename from src/features/users/admin-user-edit-modal.tsx rename to src/features/users/components/admin-user-edit-modal.tsx index aba0beb9..617c8f98 100644 --- a/src/features/users/admin-user-edit-modal.tsx +++ b/src/features/users/components/admin-user-edit-modal.tsx @@ -8,7 +8,7 @@ import { DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import {AdminUserEditForm} from "@/features/users/admin-user-edit-form"; +import {AdminUserEditForm} from "@/features/users/components/admin-user-edit-form"; type AdminUserEditPasswordProps = { open: boolean; diff --git a/src/features/users/admin-user-form.tsx b/src/features/users/components/admin-user-form.tsx similarity index 95% rename from src/features/users/admin-user-form.tsx rename to src/features/users/components/admin-user-form.tsx index d41cf3e0..f15d98c8 100644 --- a/src/features/users/admin-user-form.tsx +++ b/src/features/users/components/admin-user-form.tsx @@ -7,9 +7,9 @@ import {Organization} from "@/db/schema/03_organization"; import {Input} from "@/components/ui/input"; import {ButtonWithLoading} from "@/components/common/button-with-loading"; import {Button} from "@/components/ui/button"; -import {UserSchema, UserType} from "@/features/users/user.schema"; +import {UserSchema, UserType} from "@/features/users/schemas/user.schema"; import {toast} from "sonner"; -import {createUserAction} from "@/features/users/user.action"; +import {createUserAction} from "@/features/users/actions/user.action"; type AdminUserFormProps = { onSuccess?: () => void; diff --git a/src/features/users/components/admin-user-list.tsx b/src/features/users/components/admin-user-list.tsx new file mode 100644 index 00000000..fa4b249c --- /dev/null +++ b/src/features/users/components/admin-user-list.tsx @@ -0,0 +1,14 @@ +"use client"; +import { DataTable } from "@/components/common/data-table"; +import {usersListColumns} from "@/features/users/components/user-columns"; +import {User} from "@/db/schema/02_user"; + +type AdminUserListProps = { + users: User[]; + isPasswordAuthEnabled: boolean; + avatarUrls?: Record; +}; + +export const AdminUserList = ({ users, isPasswordAuthEnabled, avatarUrls }: AdminUserListProps) => { + return ; +}; diff --git a/src/features/users/user-actions-cell.tsx b/src/features/users/components/user-actions-cell.tsx similarity index 90% rename from src/features/users/user-actions-cell.tsx rename to src/features/users/components/user-actions-cell.tsx index 65f908fe..3e585b6f 100644 --- a/src/features/users/user-actions-cell.tsx +++ b/src/features/users/components/user-actions-cell.tsx @@ -13,10 +13,10 @@ import {MoreHorizontal, Settings, Trash2, RotateCcwKey, UserCog} from "lucide-re import {authClient} from "@/lib/auth/auth-client"; import {cn} from "@/lib/utils"; import {User} from "@/db/schema/02_user"; -import {AdminUserChangePassword} from "@/features/users/admin-user-change-password-modal"; -import {AdminUserEdit} from "@/features/users/admin-user-edit-modal"; -import {AdminUserChangeRoleModal} from "@/features/users/admin-user-change-role-modal"; -import {AdminDeleteUserModal} from "@/features/users/admin-user-delete-modal"; +import {AdminUserChangePassword} from "@/features/users/components/admin-user-change-password-modal"; +import {AdminUserEdit} from "@/features/users/components/admin-user-edit-modal"; +import {AdminUserChangeRoleModal} from "@/features/users/components/admin-user-change-role-modal"; +import {AdminDeleteUserModal} from "@/features/users/components/admin-user-delete-modal"; interface UserActionsCellProps { user: User; diff --git a/src/features/users/user-columns.tsx b/src/features/users/components/user-columns.tsx similarity index 92% rename from src/features/users/user-columns.tsx rename to src/features/users/components/user-columns.tsx index ff84be61..bb471533 100644 --- a/src/features/users/user-columns.tsx +++ b/src/features/users/components/user-columns.tsx @@ -7,13 +7,14 @@ import {Tooltip, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip" import {TooltipContent} from "@radix-ui/react-tooltip"; import {Info} from "lucide-react"; import {Table, TableBody, TableCell, TableRow} from "@/components/ui/table"; -import {UserActionsCell} from "@/features/users/user-actions-cell"; +import {UserActionsCell} from "@/features/users/components/user-actions-cell"; type UsersListColumnsProps = { isPasswordAuthEnabled: boolean; + avatarUrls?: Record; } -export function usersListColumns({ isPasswordAuthEnabled }: UsersListColumnsProps): ColumnDef[] { +export function usersListColumns({ isPasswordAuthEnabled, avatarUrls }: UsersListColumnsProps): ColumnDef[] { return [ { @@ -27,7 +28,7 @@ export function usersListColumns({ isPasswordAuthEnabled }: UsersListColumnsProp
- + {row.original.name .split(" ") diff --git a/src/features/users/user.schema.ts b/src/features/users/schemas/user.schema.ts similarity index 100% rename from src/features/users/user.schema.ts rename to src/features/users/schemas/user.schema.ts diff --git a/src/lib/api-v1/openapi/routes/agents.ts b/src/lib/api-v1/openapi/routes/agents.ts index 1f5b26f6..578304cc 100644 --- a/src/lib/api-v1/openapi/routes/agents.ts +++ b/src/lib/api-v1/openapi/routes/agents.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; import "@/lib/api-v1/openapi/registry"; -import {AgentSchema} from "@/features/agents/agents.schema"; +import {AgentSchema} from "@/features/agents/schemas/agents.schema"; import {agentSchema} from "@/db/schema/08_agent"; const UuidParam = z diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index d3958c16..a4f05cdb 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -1,812 +1,884 @@ -import {betterAuth} from "better-auth"; -import {drizzleAdapter} from "better-auth/adapters/drizzle"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; import * as drizzleDb from "@/db"; -import {db} from "@/db"; -import {env} from "@/env.mjs"; -import {nextCookies} from "better-auth/next-js"; -import {admin as adminPlugin, openAPI, Organization, organization, twoFactor} from "better-auth/plugins"; -import {ac, admin, orgAdmin, orgMember, orgOwner, pending, superadmin, user} from "@/lib/auth/permissions"; -import {headers} from "next/headers"; -import {count, eq} from "drizzle-orm"; -import {MemberWithUser, OrganizationWithMembersAndUsers,} from "@/db/schema/03_organization"; -import {sendEmail} from "@/lib/email"; -import {render} from "@react-email/render"; -import {withUpdatedAt} from "@/db/utils"; +import { db } from "@/db"; +import { env } from "@/env.mjs"; +import { nextCookies } from "better-auth/next-js"; +import { + admin as adminPlugin, + openAPI, + Organization, + organization, + twoFactor, +} from "better-auth/plugins"; +import { + ac, + admin, + orgAdmin, + orgMember, + orgOwner, + pending, + superadmin, + user, +} from "@/lib/auth/permissions"; +import { headers } from "next/headers"; +import { count, eq } from "drizzle-orm"; +import { + MemberWithUser, + OrganizationWithMembersAndUsers, +} from "@/db/schema/03_organization"; +import { sendEmail } from "@/lib/email"; +import { render } from "@react-email/render"; +import { withUpdatedAt } from "@/db/utils"; import EmailVerification from "@/components/emails/auth/email-verification"; import EmailForgotPassword from "@/components/emails/auth/email-forgot-password"; -import {getDeviceDetails} from "@/utils/detection"; +import { getDeviceDetails } from "@/utils/detection"; import EmailNewLogin from "@/components/emails/auth/email-new-login"; -import {sso} from "@better-auth/sso"; -import {SUPPORTED_PROVIDERS} from "@/lib/auth/config"; -import {passkey} from "@better-auth/passkey"; -import {getOidcProviders} from "./oidc"; -import {APIError} from "better-auth/api"; -import {getOAuthProviders} from "./oauth"; -import {logger} from "@/lib/logger"; -import {apiKey} from "@better-auth/api-key" +import { sso } from "@better-auth/sso"; +import { SUPPORTED_PROVIDERS } from "@/lib/auth/config"; +import { passkey } from "@better-auth/passkey"; +import { verifyPasskeyContext } from "@/lib/auth/passkey-context"; +import { getOidcProviders } from "./oidc"; +import { APIError } from "better-auth/api"; +import { getOAuthProviders } from "./oauth"; +import { logger } from "@/lib/logger"; +import { apiKey } from "@better-auth/api-key"; const log = logger.child({ module: "lib/auth" }); const oidcProviders = getOidcProviders(); export const auth = betterAuth({ - onAPIError: { - errorURL: "/error", - onError: (error, ctx) => { - //TODO: capture errors in a monitoring service - }, + onAPIError: { + errorURL: "/error", + onError: (error, ctx) => { + //TODO: capture errors in a monitoring service }, - database: drizzleAdapter(db, { - provider: "pg", - schema: drizzleDb.schemas, - }), - appName: env.PROJECT_NAME!, - baseURL: env.PROJECT_URL, - secret: env.PROJECT_SECRET, - emailAndPassword: { - enabled: env.AUTH_EMAIL_PASSWORD_ENABLED === "true", - requireEmailVerification: false, - sendResetPassword: async ({user, token}, request) => { - if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") { - throw new APIError("FORBIDDEN", { - message: "Password reset is disabled.", - }); - } - await db - .update(drizzleDb.schemas.user) - .set( - withUpdatedAt({ - emailVerified: true, - }), - ) - .where(eq(drizzleDb.schemas.user.id, user.id)) - .returning(); + }, + database: drizzleAdapter(db, { + provider: "pg", + schema: drizzleDb.schemas, + }), + appName: env.PROJECT_NAME!, + baseURL: env.PROJECT_URL, + secret: env.PROJECT_SECRET, + emailAndPassword: { + enabled: env.AUTH_EMAIL_PASSWORD_ENABLED === "true", + requireEmailVerification: false, + sendResetPassword: async ({ user, token }, request) => { + if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") { + throw new APIError("FORBIDDEN", { + message: "Password reset is disabled.", + }); + } + await db + .update(drizzleDb.schemas.user) + .set( + withUpdatedAt({ + emailVerified: true, + }), + ) + .where(eq(drizzleDb.schemas.user.id, user.id)) + .returning(); - await sendEmail({ - to: user.email, - subject: "Reset your password", - html: await render( - EmailForgotPassword({ - firstname: user.name!, - token, - }), - {}, - ), - }); - }, + await sendEmail({ + to: user.email, + subject: "Reset your password", + html: await render( + EmailForgotPassword({ + firstname: user.name!, + token, + }), + {}, + ), + }); }, - emailVerification: { - async sendVerificationEmail({user, token, url}) { - await sendEmail({ - to: user.email, - subject: "Portabase Email Verification", - html: await render( - EmailVerification({ - firstname: user.name, - url: url, - }), - ), - }); + }, + emailVerification: { + async sendVerificationEmail({ user, url }) { + await sendEmail({ + to: user.email, + subject: "Portabase Email Verification", + html: await render( + EmailVerification({ + firstname: user.name, + url: url, + }), + ), + }); - await ( - await auth.$context - ).internalAdapter.updateUser(user.id, { - emailVerified: false, - }); - }, - async afterEmailVerification(user) { - await ( - await auth.$context - ).internalAdapter.updateUser(user.id, { - emailVerified: true, - }); - }, + await ( + await auth.$context + ).internalAdapter.updateUser(user.id, { + emailVerified: false, + }); }, - socialProviders: getOAuthProviders().reduce< - Record - >((acc, provider) => { - const configEntry = SUPPORTED_PROVIDERS.find((p) => p.id === provider.id); + async afterEmailVerification(user) { + await ( + await auth.$context + ).internalAdapter.updateUser(user.id, { + emailVerified: true, + }); + }, + }, + socialProviders: getOAuthProviders().reduce< + Record + >((acc, provider) => { + const configEntry = SUPPORTED_PROVIDERS.find((p) => p.id === provider.id); - if (!configEntry?.isActive) { - return acc; + if (!configEntry?.isActive) { + return acc; + } + + acc[provider.id] = { + clientId: provider.client, + clientSecret: provider.secret, + ...(provider.id === "apple" && provider.appleBundleIdentifier + ? { appBundleIdentifier: provider.appleBundleIdentifier } + : {}), + }; + return acc; + }, {}), + account: { + accountLinking: { + enabled: true, + trustedProviders: [ + "credential", + ...getOAuthProviders().map((p) => p.id), + ...oidcProviders.map((p) => p.id), + ], + allowDifferentEmails: true, + }, + }, + plugins: [ + ...(env.API_ENABLED + ? [ + apiKey([ + { + configId: "public", + defaultPrefix: "pk_", + rateLimit: { + enabled: true, + maxRequests: 100, + timeWindow: 1000 * 60 * 60, + }, + }, + { + configId: "standard", + defaultPrefix: "sk_", + enableMetadata: true, + rateLimit: { + enabled: true, + maxRequests: 1000, + timeWindow: 1000 * 60 * 60, + }, + }, + { + configId: "internal", + defaultPrefix: "int_", + enableMetadata: true, + rateLimit: { + enabled: true, + maxRequests: 10_000, + timeWindow: 1000 * 60 * 60, + }, + }, + ]), + ] + : []), + sso({ + defaultSSO: oidcProviders.map((p) => ({ + oidcConfig: { + issuer: p.issuerUrl, + discoveryEndpoint: p.discoveryEndpoint, + jwksEndpoint: p.jwksEndpoint, + clientId: p.client, + clientSecret: p.secret, + scopes: p.scopes?.split(" ") ?? ["openid", "profile", "email"], + pkce: p.pkce, + mapping: { + extraFields: { + groups: "groups", + }, + }, + }, + providerId: p.id, + domain: p.host, + //@ts-ignore + issuer: p.issuerUrl, + })), + provisionUser: async ({ user: usr, userInfo, provider }) => { + const existingUser = await db.query.user.findFirst({ + where: eq(drizzleDb.schemas.user.email, usr.email), + }); + + if (existingUser && existingUser.role === "superadmin") { + return; } - acc[provider.id] = { - clientId: provider.client, - clientSecret: provider.secret, - ...(provider.id === "apple" && provider.appleBundleIdentifier - ? {appBundleIdentifier: provider.appleBundleIdentifier} - : {}), - }; - return acc; - }, {}), - account: { - accountLinking: { - enabled: true, - trustedProviders: [ - "credential", - ...getOAuthProviders().map((p) => p.id), - ...oidcProviders.map((p) => p.id), - ], - allowDifferentEmails: true, - }, - }, - plugins: [ - ...(env.API_ENABLED - ? [ - apiKey([ - { - configId: "public", - defaultPrefix: "pk_", - rateLimit: { - enabled: true, - maxRequests: 100, - timeWindow: 1000 * 60 * 60, // 100 requests / hour - }, - }, - { - configId: "standard", - defaultPrefix: "sk_", - enableMetadata: true, - rateLimit: { - enabled: true, - maxRequests: 1000, - timeWindow: 1000 * 60 * 60, // 1000 requests / hour - }, - }, - { - configId: "internal", - defaultPrefix: "int_", - enableMetadata: true, - rateLimit: { - enabled: true, - maxRequests: 10_000, - timeWindow: 1000 * 60 * 60, // 10k requests / hour - }, - }, - ]), - ] - : []), - sso({ - defaultSSO: oidcProviders.map((p) => ({ - oidcConfig: { - issuer: p.issuerUrl, - discoveryEndpoint: p.discoveryEndpoint, - jwksEndpoint: p.jwksEndpoint, - clientId: p.client, - clientSecret: p.secret, - scopes: p.scopes?.split(" ") ?? ["openid", "profile", "email"], - pkce: p.pkce, - mapping: { - extraFields: { - groups: "groups", - }, - }, - }, - providerId: p.id, - domain: p.host, - //@ts-ignore - issuer: p.issuerUrl, - })), - provisionUser: async ({user: usr, userInfo, provider}) => { - const existingUser = await db.query.user.findFirst({ - where: eq(drizzleDb.schemas.user.email, usr.email), + if (existingUser && env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN === "false") { + return; + } + + const providerId = provider.providerId; + const oidcProvider = oidcProviders.find((p) => p.id === providerId); + const allowedGroup = oidcProvider?.allowedGroup || env.ALLOWED_GROUP; + const roleMapStr = oidcProvider?.roleMap; + + const rawGroups = userInfo?.groups || userInfo?.roles || []; + + const userGroups: string[] = Array.isArray(rawGroups) + ? rawGroups.reduce((carry: string[], group) => { + carry.push(String(group).toLowerCase()); + return carry; + }, []) + : [String(rawGroups).toLowerCase()]; + + let roleToAssign: string | undefined; + + if (roleMapStr) { + const mappings = roleMapStr.split(",").map((m) => m.split(":")); + for (const [group, role] of mappings) { + if (userGroups.includes(group.trim())) { + roleToAssign = role.trim(); + break; + } + } + } + + if (!roleToAssign && allowedGroup) { + const hasAccess = userGroups.includes(allowedGroup); + + if (hasAccess) { + const userCount = ( + await db.select({ count: count() }).from(drizzleDb.schemas.user) + )[0].count; + const isSuperadmin = userCount === 0; + + roleToAssign = + allowedGroup.includes("admin") || + allowedGroup.includes("superadmin") + ? isSuperadmin + ? "superadmin" + : "admin" + : "pending"; + } + } + + if (!roleToAssign && oidcProvider?.defaultRole) { + roleToAssign = oidcProvider.defaultRole; + } + + if (!roleToAssign) { + console.warn( + `Access Denied for user ${usr.email}: No matching group/role found in ${providerId} config.`, + ); + throw new APIError("FORBIDDEN", { + message: `Access Denied: No matching roles found (${roleToAssign})`, + }); + } + + const userCount = ( + await db.select({ count: count() }).from(drizzleDb.schemas.user) + )[0].count; + if ( + userCount === 0 && + (roleToAssign === "admin" || roleToAssign === "superadmin") + ) { + roleToAssign = "superadmin"; + } + + if (existingUser) { + await db + .update(drizzleDb.schemas.user) + .set({ role: roleToAssign, emailVerified: true }) + .where(eq(drizzleDb.schemas.user.id, existingUser.id)); + } else { + return { + ...usr, + role: roleToAssign, + emailVerified: true, + }; + } + }, + }), + ...(env.AUTH_PASSKEY_ENABLED === "true" + ? [ + passkey({ + rpName: env.PROJECT_NAME || "Portabase", + rpID: env.PROJECT_URL + ? new URL(env.PROJECT_URL).hostname + : "localhost", + registration: { + requireSession: false, + resolveUser: async ({ ctx, context }) => { + const session = (ctx as any).context?.session; + if (session?.user?.id) { + return { + id: session.user.id, + name: session.user.name || session.user.email, + displayName: session.user.email, + }; + } + if (!context) + throw new APIError("BAD_REQUEST", { + message: "Passkey context required", + }); + const payload = verifyPasskeyContext(context); + if (!payload) + throw new APIError("BAD_REQUEST", { + message: "Invalid passkey context", + }); + const [existing] = await db + .select() + .from(drizzleDb.schemas.user) + .where(eq(drizzleDb.schemas.user.email, payload.email)) + .limit(1); + if (existing) { + return { + id: existing.id, + name: existing.name || payload.name, + displayName: payload.email, + }; + } + const newUser = await ( + ctx as any + ).context.internalAdapter.createUser({ + name: payload.name, + email: payload.email, + emailVerified: true, }); - - if (existingUser && existingUser.role === "superadmin") { - return; - } - - if (existingUser && env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN === "false") { - return; - } - - const providerId = provider.providerId; - const oidcProvider = oidcProviders.find((p) => p.id === providerId); - const allowedGroup = oidcProvider?.allowedGroup || env.ALLOWED_GROUP; - const roleMapStr = oidcProvider?.roleMap; - - const rawGroups = userInfo?.groups || userInfo?.roles || []; - - const userGroups: string[] = Array.isArray(rawGroups) - ? rawGroups.reduce((carry: string[], group) => { - carry.push(String(group).toLowerCase()); - return carry; - }, []) - : [String(rawGroups).toLowerCase()]; - - let roleToAssign: string | undefined; - - if (roleMapStr) { - const mappings = roleMapStr.split(",").map((m) => m.split(":")); - for (const [group, role] of mappings) { - if (userGroups.includes(group.trim())) { - roleToAssign = role.trim(); - break; - } - } - } - - if (!roleToAssign && allowedGroup) { - const hasAccess = userGroups.includes(allowedGroup); - - if (hasAccess) { - const userCount = ( - await db.select({count: count()}).from(drizzleDb.schemas.user) - )[0].count; - const isSuperadmin = userCount === 0; - - roleToAssign = - allowedGroup.includes("admin") || - allowedGroup.includes("superadmin") - ? isSuperadmin - ? "superadmin" - : "admin" - : "pending"; - } - } - - if (!roleToAssign && oidcProvider?.defaultRole) { - roleToAssign = oidcProvider.defaultRole; - } - - if (!roleToAssign) { - console.warn( - `Access Denied for user ${usr.email}: No matching group/role found in ${providerId} config.`, - ); - throw new APIError("FORBIDDEN", { - message: `Access Denied: No matching roles found (${roleToAssign})`, - }); - } - - const userCount = ( - await db.select({count: count()}).from(drizzleDb.schemas.user) - )[0].count; - if ( - userCount === 0 && - (roleToAssign === "admin" || roleToAssign === "superadmin") - ) { - roleToAssign = "superadmin"; - } - - if (existingUser) { - await db - .update(drizzleDb.schemas.user) - .set({role: roleToAssign, emailVerified: true}) - .where(eq(drizzleDb.schemas.user.id, existingUser.id)); - } else { - return { - ...usr, - role: roleToAssign, - emailVerified: true, - }; - } + return { + id: newUser.id, + name: payload.name, + displayName: payload.email, + }; + }, }, - }), - ...(env.AUTH_PASSKEY_ENABLED === "true" - ? [ - passkey({ - rpName: env.PROJECT_NAME || "Portabase", - rpID: env.PROJECT_URL - ? new URL(env.PROJECT_URL).hostname - : "localhost", - }), - ] - : []), - openAPI(), - twoFactor(), - organization({ - ac, - roles: { - owner: orgOwner, - admin: orgAdmin, - member: orgMember, - }, - }), - adminPlugin({ - adminRoles: ["admin", "superadmin"], - defaultRole: "pending", - ac, - roles: { - admin, - user, - pending, - superadmin, - }, - }), - nextCookies(), - ], - advanced: { - database: { - generateId: false, + }), + ] + : []), + openAPI(), + twoFactor(), + organization({ + ac, + roles: { + owner: orgOwner, + admin: orgAdmin, + member: orgMember, + }, + }), + adminPlugin({ + adminRoles: ["admin", "superadmin"], + defaultRole: "pending", + ac, + roles: { + admin, + user, + pending, + superadmin, + }, + }), + nextCookies(), + ], + advanced: { + database: { + generateId: false, + }, + cookies: { + state: { + attributes: { + sameSite: "none", + secure: true, }, - cookies: { - state: { - attributes: { - sameSite: "none", - secure: true, - }, - }, + }, + }, + }, + user: { + deleteUser: { + enabled: true, + }, + changeEmail: { + enabled: true, + }, + additionalFields: { + deletedAt: { + type: "number", + nullable: true, + required: false, + }, + theme: { + type: "string", + }, + lastConnectedAt: { + type: "date", + }, + lastChangedPasswordAt: { + type: "date", + }, + }, + }, + databaseHooks: { + account: { + create: { + before: async (account) => { + const provider = SUPPORTED_PROVIDERS.find( + (p) => p.id === account.providerId, + ); + + if (provider && provider.allowLinking === false) { + throw new APIError("FORBIDDEN", { + message: "Linking is disabled for this provider.", + }); + } }, + }, }, user: { - deleteUser: { - enabled: true, + update: { + async before(user) { + if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") { + if (user.password || user.lastChangedPasswordAt) { + throw new APIError("FORBIDDEN", { + message: "Password updates are disabled", + }); + } + } + return { + data: user, + }; }, - changeEmail: { - enabled: true, + }, + create: { + async before(user) { + const userCount = ( + await db.select({ count: count() }).from(drizzleDb.schemas.user) + )[0].count; + + if (env.AUTH_SIGNUP_ENABLED !== "true" && userCount > 0) { + throw new APIError("FORBIDDEN", { + message: "Sign up is disabled", + }); + } + + if (env.SKIP_ONBOARDING === "false" && userCount > 0) { + const settings = await db.query.setting.findFirst(); + if (!settings?.onboarding) { + throw new APIError("FORBIDDEN", { + message: + "Registration is disabled: onboarding not yet completed.", + }); + } + } + + const role = userCount === 0 ? "superadmin" : "pending"; + + return { + data: { + ...user, + role, + }, + }; }, - additionalFields: { - deletedAt: { - type: "number", - nullable: true, - required: false, - }, - theme: { - type: "string", - }, - lastConnectedAt: { - type: "date", - }, - lastChangedPasswordAt: { - type: "date", - }, - }, - }, - databaseHooks: { - account: { - create: { - before: async (account) => { - const provider = SUPPORTED_PROVIDERS.find( - (p) => p.id === account.providerId, - ); - - if (provider && provider.allowLinking === false) { - throw new APIError("FORBIDDEN", { - message: "Linking is disabled for this provider.", - }); - } - }, - }, - }, - user: { - update: { - async before(user, context) { - if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") { - if (user.password || user.lastChangedPasswordAt) { - throw new APIError("FORBIDDEN", { - message: "Password updates are disabled", - }); - } - } - return { - data: user, - }; - }, - }, - create: { - async before(user, context) { - const userCount = ( - await db.select({count: count()}).from(drizzleDb.schemas.user) - )[0].count; - - if (env.AUTH_SIGNUP_ENABLED !== "true" && userCount > 0) { - throw new APIError("FORBIDDEN", { - message: "Sign up is disabled", - }); - } - - const role = userCount === 0 ? "superadmin" : "pending"; - - return { - data: { - ...user, - role, - }, - }; - }, - async after(user, context) { - const userCount = ( - await db.select({count: count()}).from(drizzleDb.schemas.user) - )[0].count; - const role = userCount === 0 ? "owner" : "admin"; - - const defaultOrgSlug = "default"; - const defaultOrg = await db.query.organization.findFirst({ - where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug), - }); - - if (defaultOrg) { - await db.insert(drizzleDb.schemas.member).values({ - userId: user.id, - organizationId: defaultOrg.id, - role: role, - }); - } else { - console.warn( - "Default organization not found. Cannot assign member.", - ); - } - }, - }, - }, - session: { - create: { - before: async (session, context) => { - const userId = session.userId; - - const memberships = await db.query.member.findMany({ - where: eq(drizzleDb.schemas.member.userId, userId), - }); - - const url = - context?.request?.url || context?.headers?.get("referer") || ""; - - let providerId: string | undefined; - - if (url) { - const urlPath = new URL(url).pathname; - const pathParts = urlPath.split("/"); - const lastPathPart = pathParts[pathParts.length - 1]; - - if (urlPath.startsWith("/api/auth/sso/callback/")) { - providerId = lastPathPart; - } else if (urlPath.startsWith("/api/auth/callback/")) { - providerId = lastPathPart; - } - } - - return { - data: { - activeOrganizationId: memberships[0]?.organizationId, - providerId: providerId, - }, - }; - }, - after: async (session) => { - const user = await db.query.user.findFirst({ - where: eq(drizzleDb.schemas.user.id, session.userId), - }); - - if (!user) return; - - const createdAtDiff = - new Date(session.createdAt).getTime() - - new Date(user.createdAt).getTime(); - - if (createdAtDiff < 5000) { - log.debug(`Skipping new login email for freshly created user ${user.email}`); - return; - } - - const lastDiff = user.lastConnectedAt - ? new Date(session.createdAt).getTime() - - new Date(user.lastConnectedAt).getTime() - : Infinity; - - if (lastDiff < 30000) return; - - if (user.role === "pending") return; - - const deviceInfo = getDeviceDetails(session.userAgent); - - - try { - await sendEmail({ - to: user.email, - subject: "New login to your account", - html: await render( - EmailNewLogin({ - firstname: user.name!, - os: deviceInfo.os, - browser: deviceInfo.browser, - ipAddress: session.ipAddress!, - }), - {}, - ), - }); - } catch (error) { - log.error({ - userId: user.id, - details: "Please Check your env variables or system config", - email: user.email, - error, - }, "sendEmail failed"); - } - - (await auth.$context).internalAdapter.updateUser(user.id, { - lastConnectedAt: new Date(), - }); - }, - }, + async after(user, context) { + const userCount = ( + await db.select({ count: count() }).from(drizzleDb.schemas.user) + )[0].count; + const role = userCount === 0 ? "owner" : "admin"; + + const defaultOrgSlug = "default"; + const defaultOrg = await db.query.organization.findFirst({ + where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug), + }); + + if (defaultOrg) { + await db.insert(drizzleDb.schemas.member).values({ + userId: user.id, + organizationId: defaultOrg.id, + role: role, + }); + } else { + console.warn( + "Default organization not found. Cannot assign member.", + ); + } }, + }, }, session: { - additionalFields: { - activeOrganizationId: { - type: "string", - required: false, - }, - providerId: { - type: "string", - required: false, + create: { + before: async (session, context) => { + const userId = session.userId; + + const memberships = await db.query.member.findMany({ + where: eq(drizzleDb.schemas.member.userId, userId), + }); + + const url = + context?.request?.url || context?.headers?.get("referer") || ""; + + let providerId: string | undefined; + + if (url) { + const urlPath = new URL(url).pathname; + const pathParts = urlPath.split("/"); + const lastPathPart = pathParts[pathParts.length - 1]; + + if (urlPath.startsWith("/api/auth/sso/callback/")) { + providerId = lastPathPart; + } else if (urlPath.startsWith("/api/auth/callback/")) { + providerId = lastPathPart; + } + } + + return { + data: { + activeOrganizationId: memberships[0]?.organizationId, + providerId: providerId, }, + }; }, + after: async (session) => { + const user = await db.query.user.findFirst({ + where: eq(drizzleDb.schemas.user.id, session.userId), + }); + + if (!user) return; + + const createdAtDiff = + new Date(session.createdAt).getTime() - + new Date(user.createdAt).getTime(); + + if (createdAtDiff < 5000) { + log.debug( + `Skipping new login email for freshly created user ${user.email}`, + ); + return; + } + + const lastDiff = user.lastConnectedAt + ? new Date(session.createdAt).getTime() - + new Date(user.lastConnectedAt).getTime() + : Infinity; + + if (lastDiff < 30000) return; + + if (user.role === "pending") return; + + const deviceInfo = getDeviceDetails(session.userAgent); + + try { + await sendEmail({ + to: user.email, + subject: "New login to your account", + html: await render( + EmailNewLogin({ + firstname: user.name!, + os: deviceInfo.os, + browser: deviceInfo.browser, + ipAddress: session.ipAddress!, + }), + {}, + ), + }); + } catch (error) { + log.error( + { + userId: user.id, + details: "Please Check your env variables or system config", + email: user.email, + error, + }, + "sendEmail failed", + ); + } + + (await auth.$context).internalAdapter.updateUser(user.id, { + lastConnectedAt: new Date(), + }); + }, + }, }, - trustedOrigins: async (request) => { - const trustedOrigins = await queryTrustedDomains(); - return trustedOrigins; - } + }, + session: { + additionalFields: { + activeOrganizationId: { + type: "string", + required: false, + }, + providerId: { + type: "string", + required: false, + }, + }, + }, + trustedOrigins: async (request) => { + const trustedOrigins = await queryTrustedDomains(); + return trustedOrigins; + }, }); - const queryTrustedDomains = async (): Promise => { - const envDomains = env.TRUSTED_DOMAINS || ""; - const domains = envDomains - .split(",") - .map((d) => d.trim()) - .filter(Boolean); + const envDomains = env.TRUSTED_DOMAINS || ""; + const domains = envDomains + .split(",") + .map((d) => d.trim()) + .filter(Boolean); - if (env.PROJECT_URL) domains.push(env.PROJECT_URL); - domains.push("http://app") + if (env.PROJECT_URL) domains.push(env.PROJECT_URL); + domains.push("http://app"); - return domains; + return domains; }; export const createUser = async ( - name: string, - email: string, - password: string, - role: "user" | "pending" | "admin" | "superadmin" = "pending", + name: string, + email: string, + password: string, + role: "user" | "pending" | "admin" | "superadmin" = "pending", ) => { - return await auth.api.createUser({ - headers: await headers(), - body: { - name, - email, - password, - role, - }, - }); + return await auth.api.createUser({ + headers: await headers(), + body: { + name, + email, + password, + role, + }, + }); }; export const getSessions = async () => { - return await auth.api.listSessions({ - headers: await headers(), - }); + return await auth.api.listSessions({ + headers: await headers(), + }); }; export const getSession = async () => { - return await auth.api.getSession({ - headers: await headers(), - }); + return await auth.api.getSession({ + headers: await headers(), + }); }; export const revokeSession = async (e: string) => { - try { - const {status} = await auth.api.revokeSession({ - body: { - token: e, - }, - headers: await headers(), - }); - return status; - } catch (e) { - } + try { + const { status } = await auth.api.revokeSession({ + body: { + token: e, + }, + headers: await headers(), + }); + return status; + } catch (e) {} }; export const getAccounts = async () => { - return await auth.api.listUserAccounts({ - headers: await headers(), - }); + return await auth.api.listUserAccounts({ + headers: await headers(), + }); }; export const unlinkAccount = async (provider: string, account: string) => { - try { - const {status} = await auth.api.unlinkAccount({ - body: { - providerId: provider, - accountId: account, - }, - headers: await headers(), - }); + try { + const { status } = await auth.api.unlinkAccount({ + body: { + providerId: provider, + accountId: account, + }, + headers: await headers(), + }); - return status; - } catch (e) { - } + return status; + } catch (e) {} }; export const getOrganization = async ({ - organizationId, - organizationSlug, - }: { - organizationId?: string; - organizationSlug?: string; + organizationId, + organizationSlug, +}: { + organizationId?: string; + organizationSlug?: string; } = {}): Promise => { - const query = - organizationId != null - ? {organizationId} - : organizationSlug != null - ? {organizationSlug} - : undefined; + const query = + organizationId != null + ? { organizationId } + : organizationSlug != null + ? { organizationSlug } + : undefined; - try { - const response = await auth.api.getFullOrganization({ - headers: await headers(), - ...(query ? {query} : {}), - }); + try { + const response = await auth.api.getFullOrganization({ + headers: await headers(), + ...(query ? { query } : {}), + }); - return response as OrganizationWithMembersAndUsers; - } catch (e) { - console.error(e); - return null; - } + return response as OrganizationWithMembersAndUsers; + } catch (e) { + console.error(e); + return null; + } }; - export const getApiKeys = async () => { - return await auth.api.listApiKeys({ - headers: await headers(), - }); + return await auth.api.listApiKeys({ + headers: await headers(), + }); }; export const createApiKey = async (name: string) => { - return await auth.api.createApiKey({ - body: { - name, - configId: "standard", - }, - headers: await headers(), - }); + return await auth.api.createApiKey({ + body: { + name, + configId: "standard", + }, + headers: await headers(), + }); }; - export const deleteApiKey = async (keyId: string) => { - await auth.api.deleteApiKey({ - body: { - keyId: keyId, - configId: "standard", - }, - headers: await headers(), - }); + await auth.api.deleteApiKey({ + body: { + keyId: keyId, + configId: "standard", + }, + headers: await headers(), + }); }; export const getPasskeys = async () => { - if (env.AUTH_PASSKEY_ENABLED !== "true") return []; - const passkeys = await auth.api.listPasskeys({ - headers: await headers(), - }); + if (env.AUTH_PASSKEY_ENABLED !== "true") return []; + const passkeys = await auth.api.listPasskeys({ + headers: await headers(), + }); - return passkeys; + return passkeys; }; export const revokePasskey = async (e: string) => { - if (env.AUTH_PASSKEY_ENABLED !== "true") return; - await auth.api.deletePasskey({ - body: { - id: e, - }, - headers: await headers(), - }); + if (env.AUTH_PASSKEY_ENABLED !== "true") return; + await auth.api.deletePasskey({ + body: { + id: e, + }, + headers: await headers(), + }); }; export const listOrganizations = async (): Promise => { - try { - return (await auth.api.listOrganizations({ - headers: await headers(), - })) as Organization[]; - } catch (e) { - return null; - } + try { + return (await auth.api.listOrganizations({ + headers: await headers(), + })) as Organization[]; + } catch (e) { + return null; + } }; export const getLastOrganizationOrFirst = async (userId: string) => { - try { - const organizations = await db.query.organization.findMany({ - where: eq(drizzleDb.schemas.member.userId, userId), - }); + try { + const organizations = await db.query.organization.findMany({ + where: eq(drizzleDb.schemas.member.userId, userId), + }); - if (organizations.length > 0) { - return organizations[0].id; - } - - return null; - } catch (e) { - return null; + if (organizations.length > 0) { + return organizations[0].id; } + + return null; + } catch (e) { + return null; + } }; export const createOrganization = async (name: string, slug: string) => { - try { - return await auth.api.createOrganization({ - headers: await headers(), - body: { - name, - slug, - }, - }); - } catch (e: any) { - const errorMessage = - e?.response?.data?.message || e?.message || "Unknown auth error"; - const status = e?.response?.status || 500; + try { + return await auth.api.createOrganization({ + headers: await headers(), + body: { + name, + slug, + }, + }); + } catch (e: any) { + const errorMessage = + e?.response?.data?.message || e?.message || "Unknown auth error"; + const status = e?.response?.status || 500; - console.error("Auth API createOrganization error:", { - message: errorMessage, - status, - raw: e, - }); + console.error("Auth API createOrganization error:", { + message: errorMessage, + status, + raw: e, + }); - throw { - name: "AuthCreateOrganizationError", - message: errorMessage, - status, - cause: e, - }; - } + throw { + name: "AuthCreateOrganizationError", + message: errorMessage, + status, + cause: e, + }; + } }; export const deleteOrganization = async (organizationId: string) => { - try { - return await auth.api.deleteOrganization({ - body: { - organizationId, - }, - headers: await headers(), - }); - } catch (e: any) { - const errorMessage = - e?.response?.data?.message || e?.message || "Unknown auth error"; - const status = e?.response?.status || 500; + try { + return await auth.api.deleteOrganization({ + body: { + organizationId, + }, + headers: await headers(), + }); + } catch (e: any) { + const errorMessage = + e?.response?.data?.message || e?.message || "Unknown auth error"; + const status = e?.response?.status || 500; - console.error("Auth API deleteOrganization error:", { - message: errorMessage, - status, - raw: e, - }); + console.error("Auth API deleteOrganization error:", { + message: errorMessage, + status, + raw: e, + }); - throw { - name: "AuthDeleteOrganizationError", - message: errorMessage, - status, - cause: e, - }; - } + throw { + name: "AuthDeleteOrganizationError", + message: errorMessage, + status, + cause: e, + }; + } }; export const checkSlugOrganization = async (slug: string) => { - try { - const {status} = await auth.api.checkOrganizationSlug({ - headers: await headers(), - body: { - slug, - }, - }); + try { + const { status } = await auth.api.checkOrganizationSlug({ + headers: await headers(), + body: { + slug, + }, + }); - return status; - } catch { - } + return status; + } catch {} }; export const getActiveMember = async () => { - try { - const member = await auth.api.getActiveMember({ - headers: await headers(), - }); + try { + const member = await auth.api.getActiveMember({ + headers: await headers(), + }); - return member as MemberWithUser; - } catch (e) { - log.error({ error: e }, "Auth error"); - } + return member as MemberWithUser; + } catch (e) { + log.error({ error: e }, "Auth error"); + } }; export const setActiveOrganization = async (slug: string) => { - try { - return await auth.api.setActiveOrganization({ - headers: await headers(), - body: { - organizationSlug: slug, - }, - }); - } catch { - } + try { + return await auth.api.setActiveOrganization({ + headers: await headers(), + body: { + organizationSlug: slug, + }, + }); + } catch {} }; diff --git a/src/lib/auth/current-user.ts b/src/lib/auth/current-user.ts index 9c0440b8..a3d2419b 100644 --- a/src/lib/auth/current-user.ts +++ b/src/lib/auth/current-user.ts @@ -1,16 +1,16 @@ "use server"; import { auth } from "@/lib/auth/auth"; import { headers } from "next/headers"; -import {User} from "@/db/schema/02_user"; +import { User } from "@/db/schema/02_user"; export const currentUser = async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.user) { - return null; - } + if (!session?.user) { + return null; + } - return session.user as User; + return session.user as User; }; diff --git a/src/lib/auth/passkey-context.ts b/src/lib/auth/passkey-context.ts new file mode 100644 index 00000000..60e22534 --- /dev/null +++ b/src/lib/auth/passkey-context.ts @@ -0,0 +1,28 @@ +import { createHmac } from "crypto"; +import { env } from "@/env.mjs"; + +export function signPasskeyContext(name: string, email: string): string { + const payload = Buffer.from( + JSON.stringify({ name, email, exp: Date.now() + 5 * 60 * 1000 }), + ).toString("base64url"); + const sig = createHmac("sha256", env.PROJECT_SECRET) + .update(payload) + .digest("base64url"); + return `${payload}.${sig}`; +} + +export function verifyPasskeyContext( + token: string, +): { name: string; email: string } | null { + const dot = token.lastIndexOf("."); + if (dot === -1) return null; + const payload = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expected = createHmac("sha256", env.PROJECT_SECRET) + .update(payload) + .digest("base64url"); + if (sig !== expected) return null; + const data = JSON.parse(Buffer.from(payload, "base64url").toString()); + if (data.exp < Date.now()) return null; + return { name: data.name, email: data.email }; +} diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts index c3227201..b8c56c8c 100644 --- a/src/lib/auth/permissions.ts +++ b/src/lib/auth/permissions.ts @@ -48,7 +48,6 @@ const pending = ac.newRole({ agent: [], }); -//org const orgMember = ac.newRole({ project: ["list"], database: ["list"], diff --git a/src/lib/email/helpers.ts b/src/lib/email/helpers.ts index 48e9a724..8477da9c 100644 --- a/src/lib/email/helpers.ts +++ b/src/lib/email/helpers.ts @@ -1,16 +1,16 @@ import nodemailer from "nodemailer"; -import {Server} from "./types" +import { Server } from "./types"; export const createTransporter = (server: Server) => { - const portNumber = Number(server.port); - return nodemailer.createTransport({ - pool: true, - host: server.host, - port: portNumber, - secure: server.secure ?? portNumber === 465, - auth: { - user: server.user, - pass: server.pass, - }, - }); -}; \ No newline at end of file + const portNumber = Number(server.port); + return nodemailer.createTransport({ + pool: true, + host: server.host, + port: portNumber, + secure: server.secure ?? portNumber === 465, + auth: { + user: server.user, + pass: server.pass, + }, + }); +}; diff --git a/src/lib/email/index.ts b/src/lib/email/index.ts index c6ff38b2..f6b3f47a 100644 --- a/src/lib/email/index.ts +++ b/src/lib/email/index.ts @@ -1,42 +1,47 @@ -"use server" -import {db} from "@/db"; -import {eq} from "drizzle-orm"; +"use server"; +import { db } from "@/db"; +import { eq } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {createTransporter} from "@/lib/email/helpers"; -import {Payload} from "@/lib/email/types"; +import { createTransporter } from "@/lib/email/helpers"; +import { Payload } from "@/lib/email/types"; export const sendEmail = async (data: Payload) => { - const settings = await db - .select() - .from(drizzleDb.schemas.setting) - .where(eq(drizzleDb.schemas.setting.name, "system")) - .then((res) => res[0]); + const settings = await db + .select() + .from(drizzleDb.schemas.setting) + .where(eq(drizzleDb.schemas.setting.name, "system")) + .then((res) => res[0]); - if (!settings) { - throw new Error("SMTP system settings not found."); - } + if (!settings) { + throw new Error("SMTP system settings not found."); + } - if (!settings.smtpHost || !settings.smtpPort || !settings.smtpUser || !settings.smtpPassword || !settings.smtpFrom) { - console.warn("Incomplete SMTP settings. Email not sent."); - return; - } + if ( + !settings.smtpHost || + !settings.smtpPort || + !settings.smtpUser || + !settings.smtpPassword || + !settings.smtpFrom + ) { + console.warn("Incomplete SMTP settings. Email not sent."); + return; + } - const emailsArray = data.to.split(",") - .map(email => email.trim()); + const emailsArray = data.to.split(",").map((email) => email.trim()); - const transporter = createTransporter({ - host: settings.smtpHost, - port: Number(settings.smtpPort), - user: settings.smtpUser, - pass: settings.smtpPassword, - from: settings.smtpFrom, - secure: settings.smtpSecure ?? false, - }); - await transporter.verify(); + const transporter = createTransporter({ + host: settings.smtpHost, + port: Number(settings.smtpPort), + user: settings.smtpUser, + pass: settings.smtpPassword, + from: settings.smtpFrom, + secure: settings.smtpSecure ?? false, + }); + await transporter.verify(); - return await transporter.sendMail({ - ...data, - to: emailsArray, - from: settings.smtpFrom ?? undefined, - }); + return await transporter.sendMail({ + ...data, + to: emailsArray, + from: settings.smtpFrom ?? undefined, + }); }; diff --git a/src/lib/email/types.ts b/src/lib/email/types.ts index 31b94be6..c3a1b809 100644 --- a/src/lib/email/types.ts +++ b/src/lib/email/types.ts @@ -1,19 +1,17 @@ "use server"; - export type Payload = { - to: string; - from?: string; - subject: string; - html: any; + to: string; + from?: string; + subject: string; + html: any; }; export type Server = { - host: string; - port: number; - user: string; - pass: string; - from: string; - secure: boolean; + host: string; + port: number; + user: string; + pass: string; + from: string; + secure: boolean; }; - diff --git a/src/lib/github-releases.ts b/src/lib/github-releases.ts index cb5dea2a..2073d10a 100644 --- a/src/lib/github-releases.ts +++ b/src/lib/github-releases.ts @@ -1,108 +1,162 @@ export interface GitHubRelease { - tag_name: string; - html_url: string; - prerelease: boolean; - name: string; - body: string; + tag_name: string; + html_url: string; + prerelease: boolean; + name: string; + body: string; } type ParsedVersion = { - major: number; - minor: number; - patch: number; - rc?: number; + major: number; + minor: number; + patch: number; + rc?: number; }; function parseVersion(version: string): ParsedVersion { - const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/); - if (!match) return { - major: 0, - minor: 0, - patch: 0, - rc: undefined, + const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/); + if (!match) + return { + major: 0, + minor: 0, + patch: 0, + rc: undefined, }; - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - rc: match[4] ? Number(match[4]) : undefined, - }; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + rc: match[4] ? Number(match[4]) : undefined, + }; } -const findLatestVersion = (currentVersion: string, releases: GitHubRelease[]): GitHubRelease | null => { - const cleanCurrentVersion = currentVersion.replace(/^v/, ""); - const isStable = /^\d+\.\d+\.\d+$/.test(cleanCurrentVersion); - const isRc = /^\d+\.\d+\.\d+-rc\.\d+$/.test(cleanCurrentVersion); +const findLatestVersion = ( + currentVersion: string, + releases: GitHubRelease[], +): GitHubRelease | null => { + const cleanCurrentVersion = currentVersion.replace(/^v/, ""); + const isStable = /^\d+\.\d+\.\d+$/.test(cleanCurrentVersion); + const isRc = /^\d+\.\d+\.\d+-rc\.\d+$/.test(cleanCurrentVersion); - const currentParsedVersion = parseVersion(cleanCurrentVersion); + const currentParsedVersion = parseVersion(cleanCurrentVersion); - if (isRc) { - const latestStableVersion = releases.find(r => !r.prerelease); - const latestRcVersion = releases.find(r => r.prerelease && (r.tag_name.includes("rc") || (r.name && r.name.includes("rc")))); + if (isRc) { + const latestStableVersion = releases.find((r) => !r.prerelease); + const latestRcVersion = releases.find( + (r) => + r.prerelease && + (r.tag_name.includes("rc") || (r.name && r.name.includes("rc"))), + ); - if (latestStableVersion) { - const versionStr = latestStableVersion.tag_name || latestStableVersion.name; - const latestStableParsedVersion = parseVersion(versionStr); - if (latestStableParsedVersion.major > currentParsedVersion.major) return latestStableVersion; - if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor > currentParsedVersion.minor) return latestStableVersion; - if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor === currentParsedVersion.minor && latestStableParsedVersion.patch >= currentParsedVersion.patch) return latestStableVersion; - } - - if (latestRcVersion) { - const versionStr = latestRcVersion.tag_name || latestRcVersion.name; - const latestRcParsedVersion = parseVersion(versionStr); - if (latestRcParsedVersion.major > currentParsedVersion.major) return latestRcVersion; - if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor > currentParsedVersion.minor) return latestRcVersion; - if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor === currentParsedVersion.minor && latestRcParsedVersion.patch > currentParsedVersion.patch) return latestRcVersion; - if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor === currentParsedVersion.minor && latestRcParsedVersion.patch === currentParsedVersion.patch && - latestRcParsedVersion.rc !== undefined && currentParsedVersion.rc !== undefined && latestRcParsedVersion.rc > currentParsedVersion.rc) return latestRcVersion; - } - } else if (isStable) { - const latestStableVersion = releases.find(r => !r.prerelease); - if (latestStableVersion) { - const versionStr = latestStableVersion.tag_name || latestStableVersion.name; - const latestStableParsedVersion = parseVersion(versionStr); - - if (latestStableParsedVersion.major > currentParsedVersion.major) return latestStableVersion; - if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor > currentParsedVersion.minor) return latestStableVersion; - if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor === currentParsedVersion.minor && latestStableParsedVersion.patch > currentParsedVersion.patch) return latestStableVersion; - } + if (latestStableVersion) { + const versionStr = + latestStableVersion.tag_name || latestStableVersion.name; + const latestStableParsedVersion = parseVersion(versionStr); + if (latestStableParsedVersion.major > currentParsedVersion.major) + return latestStableVersion; + if ( + latestStableParsedVersion.major === currentParsedVersion.major && + latestStableParsedVersion.minor > currentParsedVersion.minor + ) + return latestStableVersion; + if ( + latestStableParsedVersion.major === currentParsedVersion.major && + latestStableParsedVersion.minor === currentParsedVersion.minor && + latestStableParsedVersion.patch >= currentParsedVersion.patch + ) + return latestStableVersion; } + if (latestRcVersion) { + const versionStr = latestRcVersion.tag_name || latestRcVersion.name; + const latestRcParsedVersion = parseVersion(versionStr); + if (latestRcParsedVersion.major > currentParsedVersion.major) + return latestRcVersion; + if ( + latestRcParsedVersion.major === currentParsedVersion.major && + latestRcParsedVersion.minor > currentParsedVersion.minor + ) + return latestRcVersion; + if ( + latestRcParsedVersion.major === currentParsedVersion.major && + latestRcParsedVersion.minor === currentParsedVersion.minor && + latestRcParsedVersion.patch > currentParsedVersion.patch + ) + return latestRcVersion; + if ( + latestRcParsedVersion.major === currentParsedVersion.major && + latestRcParsedVersion.minor === currentParsedVersion.minor && + latestRcParsedVersion.patch === currentParsedVersion.patch && + latestRcParsedVersion.rc !== undefined && + currentParsedVersion.rc !== undefined && + latestRcParsedVersion.rc > currentParsedVersion.rc + ) + return latestRcVersion; + } + } else if (isStable) { + const latestStableVersion = releases.find((r) => !r.prerelease); + if (latestStableVersion) { + const versionStr = + latestStableVersion.tag_name || latestStableVersion.name; + const latestStableParsedVersion = parseVersion(versionStr); + + if (latestStableParsedVersion.major > currentParsedVersion.major) + return latestStableVersion; + if ( + latestStableParsedVersion.major === currentParsedVersion.major && + latestStableParsedVersion.minor > currentParsedVersion.minor + ) + return latestStableVersion; + if ( + latestStableParsedVersion.major === currentParsedVersion.major && + latestStableParsedVersion.minor === currentParsedVersion.minor && + latestStableParsedVersion.patch > currentParsedVersion.patch + ) + return latestStableVersion; + } + } + + return null; +}; + +export const getNewRelease = async ( + currentVersion: string, +): Promise => { + try { + const response = await fetch( + "https://api.github.com/repos/Portabase/portabase/releases", + ); + if (!response.ok) { + return null; + } + + const releases: GitHubRelease[] = await response.json(); + return findLatestVersion(currentVersion, releases); + } catch (error) { + console.error("Failed to fetch latest release", error); return null; + } }; -export const getNewRelease = async (currentVersion: string): Promise => { - try { - const response = await fetch("https://api.github.com/repos/Portabase/portabase/releases"); - if (!response.ok) { - return null; - } - - const releases: GitHubRelease[] = await response.json(); - return findLatestVersion(currentVersion, releases); - - } catch (error) { - console.error("Failed to fetch latest release", error); - return null; - } -}; - -export const getNewAgentRelease = async (currentVersion: string): Promise => { - try { - const response = await fetch("https://api.github.com/repos/Portabase/agent-rust/releases", { - next: { revalidate: 3600 } - }); - if (!response.ok) { - return null; - } - - const releases: GitHubRelease[] = await response.json(); - return findLatestVersion(currentVersion, releases); - - } catch (error) { - console.error("Failed to fetch latest agent release", error); - return null; +export const getNewAgentRelease = async ( + currentVersion: string, +): Promise => { + try { + const response = await fetch( + "https://api.github.com/repos/Portabase/agent-rust/releases", + { + next: { revalidate: 3600 }, + }, + ); + if (!response.ok) { + return null; } + + const releases: GitHubRelease[] = await response.json(); + return findLatestVersion(currentVersion, releases); + } catch (error) { + console.error("Failed to fetch latest agent release", error); + return null; + } }; diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 9337e144..d788af3e 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -5,43 +5,45 @@ const defaultLevel = isProd ? "info" : "debug"; const level = (process.env.LOG_LEVEL ?? defaultLevel) as pino.Level; function getLocalTimestamp() { - const date = new Date(); + const date = new Date(); - const formatted = new Intl.DateTimeFormat("en-US", { - timeZone: process.env.TZ, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }).format(date).replace(" ", "T"); + const formatted = new Intl.DateTimeFormat("en-US", { + timeZone: process.env.TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + .format(date) + .replace(" ", "T"); - return `,"time":"${formatted}"`; + return `,"time":"${formatted}"`; } export const logger: Logger = pino({ - level: level, - base: null, - ...(isProd - ? { - timestamp: getLocalTimestamp, - formatters: { - level(label) { - return { level: label.toUpperCase() }; - } - } - } - : { - timestamp: getLocalTimestamp, - transport: { - target: "pino-pretty", - options: { - colorize: true, - translateTime: "yyyy-mm-dd HH:MM:ss", - ignore: "pid,hostname", - levelFirst: true - } - } - }) + level: level, + base: null, + ...(isProd + ? { + timestamp: getLocalTimestamp, + formatters: { + level(label) { + return { level: label.toUpperCase() }; + }, + }, + } + : { + timestamp: getLocalTimestamp, + transport: { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "yyyy-mm-dd HH:MM:ss", + ignore: "pid,hostname", + levelFirst: true, + }, + }, + }), }); diff --git a/src/lib/mcp/http-client.ts b/src/lib/mcp/http-client.ts index 0795ee24..db9aa93d 100644 --- a/src/lib/mcp/http-client.ts +++ b/src/lib/mcp/http-client.ts @@ -4,14 +4,10 @@ export type ApiV1Result = | { ok: true; status: number; data: unknown } | { ok: false; status: number; error: string }; -/** - * Thin fetch wrapper that proxies calls to the existing /api/v1 REST layer. - * Forwards the caller's API key so the REST route runs its own auth + permission checks. - */ export async function apiV1Fetch( path: string, options: RequestInit, - apiKey: string + apiKey: string, ): Promise { const url = `${getServerUrl()}${path}`; @@ -29,7 +25,6 @@ export async function apiV1Fetch( return { ok: false, status: 0, error: "Failed to reach internal API" }; } - // 204 No Content has no body if (res.status === 204) { return { ok: true, status: 204, data: null }; } @@ -53,9 +48,7 @@ export async function apiV1Fetch( } const data = - typeof body === "object" && - body !== null && - "data" in body + typeof body === "object" && body !== null && "data" in body ? (body as { data: unknown }).data : body; diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index c16ba81a..87adf727 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -14,7 +14,7 @@ import { registerBackupTools } from "./tools/backups"; */ export function createPortabaseMcpServer( _ctx: ApiKeyContext, - apiKey: string + apiKey: string, ): McpServer { const server = new McpServer({ name: "portabase", diff --git a/src/lib/mcp/tools/agents.ts b/src/lib/mcp/tools/agents.ts index a3b5a164..79f7eef5 100644 --- a/src/lib/mcp/tools/agents.ts +++ b/src/lib/mcp/tools/agents.ts @@ -1,8 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import {apiV1Fetch} from "@/lib/mcp/http-client"; -import {err, ok} from "@/lib/mcp/tools/response"; - +import { apiV1Fetch } from "@/lib/mcp/http-client"; +import { err, ok } from "@/lib/mcp/tools/response"; export function registerAgentTools(server: McpServer, apiKey: string) { server.tool( @@ -10,9 +9,13 @@ export function registerAgentTools(server: McpServer, apiKey: string) { "List all agents accessible to the authenticated user", {}, async () => { - const result = await apiV1Fetch("/api/v1/agents", { method: "GET" }, apiKey); + const result = await apiV1Fetch( + "/api/v1/agents", + { method: "GET" }, + apiKey, + ); return result.ok ? ok(result.data) : err(result.error); - } + }, ); server.tool( @@ -20,9 +23,13 @@ export function registerAgentTools(server: McpServer, apiKey: string) { "Get details for a specific agent, including its associated databases", { id: z.string().describe("Agent ID") }, async ({ id }) => { - const result = await apiV1Fetch(`/api/v1/agents/${id}`, { method: "GET" }, apiKey); + const result = await apiV1Fetch( + `/api/v1/agents/${id}`, + { method: "GET" }, + apiKey, + ); return result.ok ? ok(result.data) : err(result.error); - } + }, ); server.tool( @@ -31,7 +38,6 @@ export function registerAgentTools(server: McpServer, apiKey: string) { { name: z.string().min(1).describe("Agent name"), organizationId: z - .string() .uuid() .optional() .describe("Organization ID to scope the agent to (optional)"), @@ -40,10 +46,10 @@ export function registerAgentTools(server: McpServer, apiKey: string) { const result = await apiV1Fetch( "/api/v1/agents", { method: "POST", body: JSON.stringify({ name, organizationId }) }, - apiKey + apiKey, ); return result.ok ? ok(result.data) : err(result.error); - } + }, ); server.tool( @@ -51,11 +57,15 @@ export function registerAgentTools(server: McpServer, apiKey: string) { "Delete an agent by ID", { id: z.string().describe("Agent ID") }, async ({ id }) => { - const result = await apiV1Fetch(`/api/v1/agents/${id}`, { method: "DELETE" }, apiKey); + const result = await apiV1Fetch( + `/api/v1/agents/${id}`, + { method: "DELETE" }, + apiKey, + ); return result.ok ? ok({ message: `Agent ${id} deleted successfully` }) : err(result.error); - } + }, ); server.tool( @@ -63,8 +73,12 @@ export function registerAgentTools(server: McpServer, apiKey: string) { "Get the edge key for an agent (used by the agent to authenticate with Portabase)", { id: z.string().describe("Agent ID") }, async ({ id }) => { - const result = await apiV1Fetch(`/api/v1/agents/${id}/key`, { method: "GET" }, apiKey); + const result = await apiV1Fetch( + `/api/v1/agents/${id}/key`, + { method: "GET" }, + apiKey, + ); return result.ok ? ok(result.data) : err(result.error); - } + }, ); } diff --git a/src/lib/mcp/tools/backups.ts b/src/lib/mcp/tools/backups.ts index 0aea0d18..ce0a2c9b 100644 --- a/src/lib/mcp/tools/backups.ts +++ b/src/lib/mcp/tools/backups.ts @@ -1,7 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import {err, ok} from "@/lib/mcp/tools/response"; -import {apiV1Fetch} from "@/lib/mcp/http-client"; +import { err, ok } from "@/lib/mcp/tools/response"; +import { apiV1Fetch } from "@/lib/mcp/http-client"; export function registerBackupTools(server: McpServer, apiKey: string) { server.tool( @@ -12,10 +12,10 @@ export function registerBackupTools(server: McpServer, apiKey: string) { const result = await apiV1Fetch( `/api/v1/databases/${databaseId}/backup`, { method: "GET" }, - apiKey + apiKey, ); return result.ok ? ok(result.data) : err(result.error); - } + }, ); server.tool( @@ -29,10 +29,10 @@ export function registerBackupTools(server: McpServer, apiKey: string) { const result = await apiV1Fetch( `/api/v1/databases/${databaseId}/backup/${backupId}`, { method: "GET" }, - apiKey + apiKey, ); return result.ok ? ok(result.data) : err(result.error); - } + }, ); server.tool( @@ -43,10 +43,10 @@ export function registerBackupTools(server: McpServer, apiKey: string) { const result = await apiV1Fetch( `/api/v1/databases/${databaseId}/backup`, { method: "POST", body: JSON.stringify({}) }, - apiKey + apiKey, ); return result.ok ? ok(result.data) : err(result.error); - } + }, ); server.tool( @@ -56,7 +56,6 @@ export function registerBackupTools(server: McpServer, apiKey: string) { databaseId: z.string().describe("Database ID"), backupId: z.string().uuid().describe("Backup ID"), backupStorageId: z - .string() .uuid() .describe("Backup storage ID (from get_backup storages list)"), }, @@ -67,9 +66,9 @@ export function registerBackupTools(server: McpServer, apiKey: string) { method: "POST", body: JSON.stringify({ backupId, backupStorageId }), }, - apiKey + apiKey, ); return result.ok ? ok(result.data) : err(result.error); - } + }, ); } diff --git a/src/lib/mcp/tools/databases.ts b/src/lib/mcp/tools/databases.ts index 7c39d4ff..614d0fb5 100644 --- a/src/lib/mcp/tools/databases.ts +++ b/src/lib/mcp/tools/databases.ts @@ -1,41 +1,48 @@ -import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js"; -import {z} from "zod"; -import {err, ok} from "@/lib/mcp/tools/response"; -import {apiV1Fetch} from "@/lib/mcp/http-client"; - +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { err, ok } from "@/lib/mcp/tools/response"; +import { apiV1Fetch } from "@/lib/mcp/http-client"; export function registerDatabaseTools(server: McpServer, apiKey: string) { - server.tool( - "list_databases", - "List all databases accessible to the authenticated user", - {}, - async () => { - const result = await apiV1Fetch("/api/v1/databases", {method: "GET"}, apiKey); - return result.ok ? ok(result.data) : err(result.error); - } - ); + server.tool( + "list_databases", + "List all databases accessible to the authenticated user", + {}, + async () => { + const result = await apiV1Fetch( + "/api/v1/databases", + { method: "GET" }, + apiKey, + ); + return result.ok ? ok(result.data) : err(result.error); + }, + ); - server.tool( - "get_database", - "Get details for a specific database", - {id: z.string().describe("Database ID")}, - async ({id}) => { - const result = await apiV1Fetch(`/api/v1/databases/${id}`, {method: "GET"}, apiKey); - return result.ok ? ok(result.data) : err(result.error); - } - ); + server.tool( + "get_database", + "Get details for a specific database", + { id: z.string().describe("Database ID") }, + async ({ id }) => { + const result = await apiV1Fetch( + `/api/v1/databases/${id}`, + { method: "GET" }, + apiKey, + ); + return result.ok ? ok(result.data) : err(result.error); + }, + ); - server.tool( - "get_database_status", - "Get the current status of a database, including latest backup and restoration state", - {id: z.string().describe("Database ID")}, - async ({id}) => { - const result = await apiV1Fetch( - `/api/v1/databases/${id}/status`, - {method: "GET"}, - apiKey - ); - return result.ok ? ok(result.data) : err(result.error); - } - ); + server.tool( + "get_database_status", + "Get the current status of a database, including latest backup and restoration state", + { id: z.string().describe("Database ID") }, + async ({ id }) => { + const result = await apiV1Fetch( + `/api/v1/databases/${id}/status`, + { method: "GET" }, + apiKey, + ); + return result.ok ? ok(result.data) : err(result.error); + }, + ); } diff --git a/src/lib/mcp/tools/response.ts b/src/lib/mcp/tools/response.ts index 7e172aac..840dc214 100644 --- a/src/lib/mcp/tools/response.ts +++ b/src/lib/mcp/tools/response.ts @@ -1,12 +1,12 @@ export function ok(data: unknown) { - return { - content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], - }; + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + }; } export function err(message: string) { - return { - content: [{ type: "text" as const, text: message }], - isError: true as const, - }; -} \ No newline at end of file + return { + content: [{ type: "text" as const, text: message }], + isError: true as const, + }; +} diff --git a/src/lib/safe-actions/actions.ts b/src/lib/safe-actions/actions.ts index aba266c6..148179a9 100644 --- a/src/lib/safe-actions/actions.ts +++ b/src/lib/safe-actions/actions.ts @@ -1,29 +1,29 @@ -import {createSafeActionClient} from "next-safe-action"; -import {currentUser} from "@/lib/auth/current-user"; +import { createSafeActionClient } from "next-safe-action"; +import { currentUser } from "@/lib/auth/current-user"; export class ActionError extends Error { - constructor(message: string) { - super(message); - this.name = "ActionError"; - } + constructor(message: string) { + super(message); + this.name = "ActionError"; + } } const handleReturnedServerError = (error: Error) => { - if (error instanceof ActionError) { - return error.message; - } else { - return "An unexpected error occurred."; - } + if (error instanceof ActionError) { + return error.message; + } else { + return "An unexpected error occurred."; + } }; export const action = createSafeActionClient({ - handleServerError: handleReturnedServerError, + handleServerError: handleReturnedServerError, }); -export const userAction = action.use(async ({next, ctx}) => { - const user = await currentUser(); - if (!user) { - throw new ActionError("You must be logged in"); - } - return next({ctx: {user}}); +export const userAction = action.use(async ({ next }) => { + const user = await currentUser(); + if (!user) { + throw new ActionError("You must be logged in"); + } + return next({ ctx: { user } }); }); diff --git a/src/lib/tasks/database/index.ts b/src/lib/tasks/database/index.ts index 337bf469..69a24435 100644 --- a/src/lib/tasks/database/index.ts +++ b/src/lib/tasks/database/index.ts @@ -1,58 +1,57 @@ -import {db} from "@/db"; -import {enforceRetentionCount} from "@/lib/tasks/database/retention-count"; -import {enforceRetentionDays} from "@/lib/tasks/database/retention-days"; -import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf"; -import {retentionPolicy} from "@/db/schema/07_database"; -import {isNull} from "drizzle-orm"; +import { db } from "@/db"; +import { enforceRetentionCount } from "@/lib/tasks/database/retention-count"; +import { enforceRetentionDays } from "@/lib/tasks/database/retention-days"; +import { enforceRetentionGFS } from "@/lib/tasks/database/retention-gsf"; +import { retentionPolicy } from "@/db/schema/07_database"; +import { isNull } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {logger} from "@/lib/logger"; +import { logger } from "@/lib/logger"; -const log = logger.child({module: "tasks/database"}); +const log = logger.child({ module: "tasks/database" }); export const retentionCleanTask = async () => { - try { - const databases = await db.query.database.findMany({ - with: { - retentionPolicy: true, - backups: { - where: isNull(drizzleDb.schemas.backup.deletedAt), - }, - }, - }); - log.info(`Retention databases number: ${databases.length}`); - for (const db of databases) { - if (!db.retentionPolicy) continue; - await enforceRetention(db.id, db.retentionPolicy); - } - } catch (e: any) { - log.error({error: e},"Retention cleanup failed"); - throw e; + try { + const databases = await db.query.database.findMany({ + with: { + retentionPolicy: true, + backups: { + where: isNull(drizzleDb.schemas.backup.deletedAt), + }, + }, + }); + log.info(`Retention databases number: ${databases.length}`); + for (const db of databases) { + if (!db.retentionPolicy) continue; + await enforceRetention(db.id, db.retentionPolicy); } + } catch (e: any) { + log.error({ error: e }, "Retention cleanup failed"); + throw e; + } }; export async function enforceRetention( - databaseId: string, - policy: typeof retentionPolicy.$inferSelect + databaseId: string, + policy: typeof retentionPolicy.$inferSelect, ) { -; - log.info({name: "enforceRetention"},`Retention started for ${databaseId}`); + log.info({ name: "enforceRetention" }, `Retention started for ${databaseId}`); - switch (policy.type) { - case "count": - await enforceRetentionCount(databaseId, policy.count ?? 7); - break; + switch (policy.type) { + case "count": + await enforceRetentionCount(databaseId, policy.count ?? 7); + break; - case "days": - await enforceRetentionDays(databaseId, policy.days ?? 30); - break; + case "days": + await enforceRetentionDays(databaseId, policy.days ?? 30); + break; - case "gfs": - await enforceRetentionGFS(databaseId, { - daily: policy.gfsDaily ?? 7, - weekly: policy.gfsWeekly ?? 4, - monthly: policy.gfsMonthly ?? 12, - yearly: policy.gfsYearly ?? 3, - }); - break; - } + case "gfs": + await enforceRetentionGFS(databaseId, { + daily: policy.gfsDaily ?? 7, + weekly: policy.gfsWeekly ?? 4, + monthly: policy.gfsMonthly ?? 12, + yearly: policy.gfsYearly ?? 3, + }); + break; + } } diff --git a/src/lib/tasks/database/retention-days.ts b/src/lib/tasks/database/retention-days.ts index 198fa223..942bc2ac 100644 --- a/src/lib/tasks/database/retention-days.ts +++ b/src/lib/tasks/database/retention-days.ts @@ -1,45 +1,51 @@ -import {db} from "@/db"; -import {eq, lt, and, desc, isNull} from "drizzle-orm"; +import { db } from "@/db"; +import { eq, lt, and, isNull } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; -import {logger} from "@/lib/logger"; +import { deleteBackupCronAction } from "@/lib/tasks/database/utils/delete"; +import { logger } from "@/lib/logger"; -const log = logger.child({module: "tasks/database/retention-days"}); +const log = logger.child({ module: "tasks/database/retention-days" }); export async function enforceRetentionDays(databaseId: string, days: number) { - log.info({ name: "enforceRetentionDays"}, `Enforce Retention Days starting for ${databaseId}`); + log.info( + { name: "enforceRetentionDays" }, + `Enforce Retention Days starting for ${databaseId}`, + ); - const cutoff = new Date(Date.now() - days * 86400000); + const cutoff = new Date(Date.now() - days * 86400000); - const expiredBackups = await db.query.backup.findMany({ - where: and( - eq(drizzleDb.schemas.backup.databaseId, databaseId), - lt(drizzleDb.schemas.backup.createdAt, cutoff), - isNull(drizzleDb.schemas.backup.deletedAt) - ), + const expiredBackups = await db.query.backup.findMany({ + where: and( + eq(drizzleDb.schemas.backup.databaseId, databaseId), + lt(drizzleDb.schemas.backup.createdAt, cutoff), + isNull(drizzleDb.schemas.backup.deletedAt), + ), + with: { + database: { with: { - database: { - with: { - project: true - } - } - } + project: true, + }, + }, + }, + }); + + for (const backup of expiredBackups) { + const result = await deleteBackupCronAction({ + backupId: backup.id, + databaseId: backup.databaseId, }); - for (const backup of expiredBackups) { - - const result = await deleteBackupCronAction({ - backupId: backup.id, - databaseId: backup.databaseId, - }); - - const inner = result?.data; - if (inner?.success) { - log.info({ name: "enforceRetentionDays"}, `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`); - } else { - log.info({ name: "enforceRetentionDays"}, `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : an error occurred - ${inner?.actionError?.message}`); - } + const inner = result?.data; + if (inner?.success) { + log.info( + { name: "enforceRetentionDays" }, + `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`, + ); + } else { + log.info( + { name: "enforceRetentionDays" }, + `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : an error occurred - ${inner?.actionError?.message}`, + ); } - + } } - diff --git a/src/lib/tasks/database/retention-gsf.ts b/src/lib/tasks/database/retention-gsf.ts index e3b0271c..0db4a0ff 100644 --- a/src/lib/tasks/database/retention-gsf.ts +++ b/src/lib/tasks/database/retention-gsf.ts @@ -1,81 +1,106 @@ -import {db} from "@/db"; -import {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, startOfYear} from "date-fns"; -import {eq, desc, isNull, and} from "drizzle-orm"; +import { db } from "@/db"; +import { + subDays, + subWeeks, + subMonths, + subYears, + startOfWeek, + startOfMonth, + startOfYear, +} from "date-fns"; +import { eq, desc, isNull, and } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; -import {logger} from "@/lib/logger"; +import { deleteBackupCronAction } from "@/lib/tasks/database/utils/delete"; +import { logger } from "@/lib/logger"; -const log = logger.child({module: "tasks/database/retention-gsf"}); +const log = logger.child({ module: "tasks/database/retention-gsf" }); -export async function enforceRetentionGFS(databaseId: string, gfsSettings: { +export async function enforceRetentionGFS( + databaseId: string, + gfsSettings: { daily: number; weekly: number; monthly: number; yearly: number; -}) { - log.info({ name: "enforceRetentionGFS"}, `Retention GFS started for databaseId: ${databaseId}`); + }, +) { + log.info( + { name: "enforceRetentionGFS" }, + `Retention GFS started for databaseId: ${databaseId}`, + ); - const backups = await db.query.backup.findMany({ - where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)), - orderBy: desc(drizzleDb.schemas.backup.createdAt), + const backups = await db.query.backup.findMany({ + where: and( + eq(drizzleDb.schemas.backup.databaseId, databaseId), + isNull(drizzleDb.schemas.backup.deletedAt), + ), + orderBy: desc(drizzleDb.schemas.backup.createdAt), + with: { + database: { with: { - database: { - with: { - project: true - } - } - } - }); + project: true, + }, + }, + }, + }); - const now = new Date(); - const toKeep: Set = new Set(); + const now = new Date(); + const toKeep: Set = new Set(); - // DAILY - backups.forEach((b) => { - if (b.createdAt >= subDays(now, gfsSettings.daily)) toKeep.add(b.id); - }); + backups.forEach((b) => { + if (b.createdAt >= subDays(now, gfsSettings.daily)) toKeep.add(b.id); + }); - const weekStartDates = Array.from({length: gfsSettings.weekly}, (_, i) => startOfWeek(subWeeks(now, i), {weekStartsOn: 1})); - weekStartDates.forEach((weekStart) => { - const backupOfWeek = backups.find( - (b) => b.createdAt >= weekStart && b.createdAt < subWeeks(weekStart, -1) + const weekStartDates = Array.from({ length: gfsSettings.weekly }, (_, i) => + startOfWeek(subWeeks(now, i), { weekStartsOn: 1 }), + ); + weekStartDates.forEach((weekStart) => { + const backupOfWeek = backups.find( + (b) => b.createdAt >= weekStart && b.createdAt < subWeeks(weekStart, -1), + ); + if (backupOfWeek) toKeep.add(backupOfWeek.id); + }); + + const monthStartDates = Array.from({ length: gfsSettings.monthly }, (_, i) => + startOfMonth(subMonths(now, i)), + ); + monthStartDates.forEach((monthStart) => { + const backupOfMonth = backups.find( + (b) => + b.createdAt >= monthStart && b.createdAt < subMonths(monthStart, -1), + ); + if (backupOfMonth) toKeep.add(backupOfMonth.id); + }); + + const yearStartDates = Array.from({ length: gfsSettings.yearly }, (_, i) => + startOfYear(subYears(now, i)), + ); + yearStartDates.forEach((yearStart) => { + const backupOfYear = backups.find( + (b) => b.createdAt >= yearStart && b.createdAt < subYears(yearStart, -1), + ); + if (backupOfYear) toKeep.add(backupOfYear.id); + }); + + for (const b of backups) { + if (!toKeep.has(b.id)) { + const result = await deleteBackupCronAction({ + backupId: b.id, + databaseId: b.databaseId, + }); + + const inner = result?.data; + if (inner?.success) { + log.info( + { name: "enforceRetentionGFS" }, + `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`, ); - if (backupOfWeek) toKeep.add(backupOfWeek.id); - }); - - // MONTHLY - const monthStartDates = Array.from({length: gfsSettings.monthly}, (_, i) => startOfMonth(subMonths(now, i))); - monthStartDates.forEach((monthStart) => { - const backupOfMonth = backups.find( - (b) => b.createdAt >= monthStart && b.createdAt < subMonths(monthStart, -1) + } else { + log.info( + { name: "enforceRetentionGFS" }, + `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`, ); - if (backupOfMonth) toKeep.add(backupOfMonth.id); - }); - - // YEARLY - const yearStartDates = Array.from({length: gfsSettings.yearly}, (_, i) => startOfYear(subYears(now, i))); - yearStartDates.forEach((yearStart) => { - const backupOfYear = backups.find( - (b) => b.createdAt >= yearStart && b.createdAt < subYears(yearStart, -1) - ); - if (backupOfYear) toKeep.add(backupOfYear.id); - }); - - // Delete backups not in `toKeep` - for (const b of backups) { - if (!toKeep.has(b.id)) { - - const result = await deleteBackupCronAction({ - backupId: b.id, - databaseId: b.databaseId, - }); - - const inner = result?.data; - if (inner?.success) { - log.info({ name: "enforceRetentionGFS"}, `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`); - } else { - log.info({ name: "enforceRetentionGFS"}, `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`); - } - } + } } + } } diff --git a/src/lib/tasks/database/utils/delete.ts b/src/lib/tasks/database/utils/delete.ts index ce518243..f84552a5 100644 --- a/src/lib/tasks/database/utils/delete.ts +++ b/src/lib/tasks/database/utils/delete.ts @@ -1,97 +1,104 @@ -"use server" -import {action} from "@/lib/safe-actions/actions"; -import {z} from "zod"; -import {ServerActionResult} from "@/types/action-type"; -import {Backup} from "@/db/schema/07_database"; -import {db} from "@/db"; +"use server"; +import { action } from "@/lib/safe-actions/actions"; +import { z } from "zod"; +import { ServerActionResult } from "@/types/action-type"; +import { Backup } from "@/db/schema/07_database"; +import { db } from "@/db"; import * as drizzleDb from "@/db"; -import {and, eq} from "drizzle-orm"; -import {withUpdatedAt} from "@/db/utils"; -import type {StorageInput} from "@/features/storages/storages.types"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; - +import { and, eq } from "drizzle-orm"; +import { withUpdatedAt } from "@/db/utils"; +import type { StorageInput } from "@/features/storages/types"; +import { dispatchStorage } from "@/features/storages/utils/storages.dispatch"; export const deleteBackupCronAction = action - .schema( - z.object({ - backupId: z.string(), - databaseId: z.string(), - }) - ) - .action(async ({parsedInput}): Promise> => { - try { + .schema( + z.object({ + backupId: z.string(), + databaseId: z.string(), + }), + ) + .action(async ({ parsedInput }): Promise> => { + try { + const backup = await db.query.backup.findFirst({ + where: and( + eq(drizzleDb.schemas.backup.id, parsedInput.backupId), + eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId), + ), + }); - const backup = await db.query.backup.findFirst({ - where: and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)) - }); + if (!backup) { + return { + success: false, + actionError: { + message: "Backup not found.", + status: 404, + messageParams: { backupId: parsedInput.backupId }, + }, + }; + } + const backupStorages = await db.query.backupStorage.findMany({ + where: eq( + drizzleDb.schemas.backupStorage.backupId, + parsedInput.backupId, + ), + }); - if (!backup) { - return { - success: false, - actionError: { - message: "Backup not found.", - status: 404, - messageParams: {backupId: parsedInput.backupId}, - }, - } - } + for (const backupStorage of backupStorages) { + await db + .update(drizzleDb.schemas.backupStorage) + .set( + withUpdatedAt({ + deletedAt: new Date(), + }), + ) + .where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id)); - - const backupStorages = await db.query.backupStorage.findMany({ - where: eq(drizzleDb.schemas.backupStorage.backupId, parsedInput.backupId), - }); - - - for (const backupStorage of backupStorages) { - - await db - .update(drizzleDb.schemas.backupStorage) - .set(withUpdatedAt({ - deletedAt: new Date(), - })) - .where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id)) - - if (backupStorage.status != "success" || !backupStorage.path) { - continue; - } - - const input: StorageInput = { - action: "delete", - data: { - path: backupStorage.path, - }, - }; - - await dispatchStorage(input, undefined, backupStorage.storageChannelId); - } - - await db - .update(drizzleDb.schemas.backup) - .set(withUpdatedAt({ - deletedAt: new Date(), - status: backup.status == "ongoing" ? "failed" : backup.status - - })) - .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))) - - return { - success: true, - actionSuccess: { - message: `Backup deleted successfully (${parsedInput.backupId}).`, - }, - }; - - } catch (error) { - console.error(error); - return { - success: false, - actionError: { - message: `Failed to delete backup(${parsedInput.backupId}).`, - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {message: "Error deleting the backup"}, - }, - }; + if (backupStorage.status != "success" || !backupStorage.path) { + continue; } - }); \ No newline at end of file + + const input: StorageInput = { + action: "delete", + data: { + path: backupStorage.path, + }, + }; + + await dispatchStorage(input, undefined, backupStorage.storageChannelId); + } + + await db + .update(drizzleDb.schemas.backup) + .set( + withUpdatedAt({ + deletedAt: new Date(), + status: backup.status == "ongoing" ? "failed" : backup.status, + }), + ) + .where( + and( + eq(drizzleDb.schemas.backup.id, parsedInput.backupId), + eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId), + ), + ); + + return { + success: true, + actionSuccess: { + message: `Backup deleted successfully (${parsedInput.backupId}).`, + }, + }; + } catch (error) { + console.error(error); + return { + success: false, + actionError: { + message: `Failed to delete backup(${parsedInput.backupId}).`, + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: { message: "Error deleting the backup" }, + }, + }; + } + }); diff --git a/src/lib/twx.tsx b/src/lib/twx.tsx index 721579d1..ee4a3fb3 100644 --- a/src/lib/twx.tsx +++ b/src/lib/twx.tsx @@ -1,4 +1,4 @@ import { createTwc } from "react-twc"; import { cn } from "./utils"; -export const twx = createTwc({ compose: cn}); +export const twx = createTwc({ compose: cn }); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391d..a5ef1935 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/src/lib/zod.ts b/src/lib/zod.ts index 151903f0..c3f02c9b 100644 --- a/src/lib/zod.ts +++ b/src/lib/zod.ts @@ -1,24 +1,25 @@ import { z } from "zod"; -export const zString = () => - z.string(); +export const zString = () => z.string(); -export const zEnum = (values: T) => z.enum(values, { message: "Field required" }); +export const zEnum = (values: T) => + z.enum(values, { message: "Field required" }); -export const zEmail = () => z.string().email({ message: "Invalid email" }); +export const zEmail = () => z.email({ message: "Invalid email" }); -const passwordRegex = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-])/; +const passwordRegex = + /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-])/; -export const zPassword = () => zString().min(8, { message: "New Password too short" }).regex(passwordRegex, { message: "New password too weak" }); +export const zPassword = () => + zString() + .min(8, { message: "New Password too short" }) + .regex(passwordRegex, { message: "New password too weak" }); export const zDate = () => - z.preprocess( - (arg) => { - if (typeof arg === "string" || arg instanceof Date) { - const date = new Date(arg); - return isNaN(date.getTime()) ? undefined : date; - } - return undefined; - }, - z.date() - ); + z.preprocess((arg) => { + if (typeof arg === "string" || arg instanceof Date) { + const date = new Date(arg); + return isNaN(date.getTime()) ? undefined : date; + } + return undefined; + }, z.date()); diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index 227b22fb..e59be2da 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,8 +1,11 @@ -import {NextResponse} from "next/server"; +import { NextResponse } from "next/server"; export const errorHandler = (error: any) => { - return new NextResponse( - JSON.stringify({ message: 'An error occurred while processing your request.', status: 500 }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ); -}; \ No newline at end of file + return new NextResponse( + JSON.stringify({ + message: "An error occurred while processing your request.", + status: 500, + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); +}; diff --git a/src/middleware/loggingMiddleware.ts b/src/middleware/loggingMiddleware.ts index 532bfe6d..9d209928 100644 --- a/src/middleware/loggingMiddleware.ts +++ b/src/middleware/loggingMiddleware.ts @@ -1,7 +1,8 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { logger } from "@/lib/logger"; +import { NextRequest, NextResponse } from "next/server"; const LOG_LEVELS = ["debug", "info", "warn", "error"] as const; -type LogLevel = typeof LOG_LEVELS[number]; +type LogLevel = (typeof LOG_LEVELS)[number]; const raw = (process.env.LOG_LEVEL ?? "info") as LogLevel; const _idx = LOG_LEVELS.indexOf(raw); @@ -10,13 +11,14 @@ const LEVEL_INDEX = _idx === -1 ? 1 : _idx; const STATUS_PATH_RE = /\/api\/agent\/[^/]+\/status\/?$/; export function loggingMiddleware(request: NextRequest) { - if (request.url.includes('/api')) { - const isStatusPing = STATUS_PATH_RE.test(request.nextUrl.pathname); - // Status pings are debug-level; all other API calls are info-level. - const requiredIndex = isStatusPing ? 0 : 1; - if (LEVEL_INDEX <= requiredIndex) { - console.log(`[API] Received ${request.method} request : ${request.url} at ${new Date()}`); - } + if (request.url.includes("/api")) { + const isStatusPing = STATUS_PATH_RE.test(request.nextUrl.pathname); + const requiredIndex = isStatusPing ? 0 : 1; + if (LEVEL_INDEX <= requiredIndex) { + logger.info( + `[API] Received ${request.method} request : ${request.url} at ${new Date()}`, + ); } - return NextResponse.next(); -} \ No newline at end of file + } + return NextResponse.next(); +} diff --git a/src/types/action-type.ts b/src/types/action-type.ts index ac18c0e3..f0d6b143 100644 --- a/src/types/action-type.ts +++ b/src/types/action-type.ts @@ -2,30 +2,33 @@ import { SafeActionResult } from "next-safe-action"; import { ZodString } from "zod"; export type ServerActionResult = - | { success: true; value?: T; actionSuccess?: ActionSuccessMessage } - | { - success: false; - actionError?: ActionErrorMessage; - }; + | { success: true; value?: T; actionSuccess?: ActionSuccessMessage } + | { + success: false; + actionError?: ActionErrorMessage; + }; export type ActionErrorMessage = { - message?: string; - status?: number; - cause?: string; - messageParams?: Record; + message?: string; + status?: number; + cause?: string; + messageParams?: Record; }; -export type ActionSuccessMessage = { message?: string; messageParams?: Record }; +export type ActionSuccessMessage = { + message?: string; + messageParams?: Record; +}; export type ActionResult = SafeActionResult< - string, - ZodString, - readonly [], - { - formErrors: string[]; - fieldErrors: {}; - }, - readonly [], - ServerActionResult, - object + string, + ZodString, + readonly [], + { + formErrors: string[]; + fieldErrors: {}; + }, + readonly [], + ServerActionResult, + object >; diff --git a/src/types/auth.ts b/src/types/auth.ts index d3727eef..bf2cba27 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,17 +1,16 @@ export type SignUpUser = { - name: string - email: string - password: string - callbackURL?: string - role?: string - theme: string - emailVerified?: boolean -} - + name: string; + email: string; + password: string; + callbackURL?: string; + role?: string; + theme: string; + emailVerified?: boolean; +}; export type BetterAuthError = { - code?: string; - message?: string; - status: number; - statusText: string; -}; \ No newline at end of file + code?: string; + message?: string; + status: number; + statusText: string; +}; diff --git a/src/types/common.ts b/src/types/common.ts index 8318e502..290a441e 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,2 +1,2 @@ export type MemberRole = "member" | "admin" | "owner"; -export type MemberRoleType = MemberRole | MemberRole[]; \ No newline at end of file +export type MemberRoleType = MemberRole | MemberRole[]; diff --git a/src/types/next.ts b/src/types/next.ts index 08e428b9..d1db265b 100644 --- a/src/types/next.ts +++ b/src/types/next.ts @@ -1,9 +1,9 @@ export type LayoutParams> = { - children: React.ReactNode; - params: T; + children: React.ReactNode; + params: T; }; export type PageParams> = { - params: Promise; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + params: Promise; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; diff --git a/src/utils/common.ts b/src/utils/common.ts index 1fefdead..ee1f4b8d 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,89 +1,91 @@ -import {MemberWithUser, Organization, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization"; -import {OrganizationMember} from "@/db/schema/04_member"; -import {OrganizationInvitation} from "@/db/schema/05_invitation"; -import {User} from "@/db/schema/02_user"; - +import { + MemberWithUser, + Organization, + OrganizationWithMembersAndUsers, +} from "@/db/schema/03_organization"; +import { OrganizationMember } from "@/db/schema/04_member"; +import { OrganizationInvitation } from "@/db/schema/05_invitation"; +import { User } from "@/db/schema/02_user"; export function buildOrganizationWithMembers( - rows: { - organization: Organization; - member: OrganizationMember | null; - invitation: OrganizationInvitation | null; - user: User | null; - }[] + rows: { + organization: Organization; + member: OrganizationMember | null; + invitation: OrganizationInvitation | null; + user: User | null; + }[], ): OrganizationWithMembersAndUsers | null { - if (rows.length === 0) return null; + if (rows.length === 0) return null; - const org = rows[0].organization; + const org = rows[0].organization; + const invitations: OrganizationInvitation[] = rows + .filter((r) => r.invitation) + .map((r) => ({ + ...r.invitation!, + })); - const invitations: OrganizationInvitation[] = rows - .filter(r => r.invitation) - .map(r => ({ - ...r.invitation!, - })); + const members: MemberWithUser[] = rows + .filter((r) => r.member && r.user) + .map((r) => ({ + ...r.member!, + user: r.user!, + })); - const members: MemberWithUser[] = rows - .filter(r => r.member && r.user) - .map(r => ({ - ...r.member!, - user: r.user!, - })); - - return { - ...org, - invitations, - members, - }; + return { + ...org, + invitations, + members, + }; } - export function getFileExtension(dbType: string) { - switch (dbType) { - case "postgresql": - return ".dump"; - case "mysql": - return ".sql"; - default: - return ".dump"; - } + switch (dbType) { + case "postgresql": + return ".dump"; + case "mysql": + return ".sql"; + default: + return ".dump"; + } } -export function getFileHeadersBasedOnDbms(dbType: string): Record { - switch (dbType) { - case "postgresql": - return { - "application/octet-stream": [".dump"], - }; - case "mysql": - case "mariadb": - return { - "application/sql": [".sql"], - "application/x-sql": [".sql"], - }; - case "mongodb": - return { - "application/gzip": [".archive.gz"], - }; - case "firebird": - return { - "application/octet-stream": [".fbk"], - }; - case "valkey": - case "redis": - return { - "application/octet-stream": [".rdb"], - }; - case "sqlite": - return { - "application/octet-stream": [".backup"], - }; - case "mssql": - return { - "application/octet-stream": [".bacpac"], - }; - default: - throw new Error(`Unsupported database type: ${dbType}`); - - } +export function getFileHeadersBasedOnDbms( + dbType: string, +): Record { + switch (dbType) { + case "postgresql": + return { + "application/octet-stream": [".dump"], + }; + case "mysql": + case "mariadb": + return { + "application/sql": [".sql"], + "application/x-sql": [".sql"], + }; + case "mongodb": + return { + "application/gzip": [".archive.gz"], + }; + case "firebird": + return { + "application/octet-stream": [".fbk"], + }; + case "valkey": + case "redis": + return { + "application/octet-stream": [".rdb"], + }; + case "sqlite": + return { + "application/octet-stream": [".backup"], + }; + case "mssql": + return { + "application/octet-stream": [".bacpac"], + }; + default: + throw new Error(`Unsupported database type: ${dbType}`); + } } diff --git a/src/utils/edge_key.ts b/src/utils/edge_key.ts index 0e1d0ef0..f27ae519 100644 --- a/src/utils/edge_key.ts +++ b/src/utils/edge_key.ts @@ -1,5 +1,5 @@ "use server" -import {getMasterServerKeyContent} from "@/features/agents/keys.action"; +import {getMasterServerKeyContent} from "@/features/agents/actions/keys.action"; export async function generateEdgeKey(serverUrl: string, agentId: string): Promise { const masterKey = await getMasterServerKeyContent() diff --git a/src/utils/init/index.ts b/src/utils/init/index.ts index 9108fb8e..70ef9f45 100644 --- a/src/utils/init/index.ts +++ b/src/utils/init/index.ts @@ -6,6 +6,7 @@ import {setupCronJobs} from "@/utils/init/cron"; import {createSettingsIfNotExist} from "@/utils/init/setting"; import {createDefaultOrganization} from "@/utils/init/organization"; import {createDefaultUser} from "@/utils/init/user"; +import {markOnboardingDoneIfSkipped} from "@/utils/init/onboarding"; const log = logger.child({module: "init"}); @@ -18,6 +19,7 @@ export async function init() { await createDefaultOrganization(); await createSettingsIfNotExist(); await createDefaultUser(); + await markOnboardingDoneIfSkipped(); log.info("====Initialization completed===="); await setupCronJobs(); if ( diff --git a/src/utils/init/onboarding.ts b/src/utils/init/onboarding.ts new file mode 100644 index 00000000..43796435 --- /dev/null +++ b/src/utils/init/onboarding.ts @@ -0,0 +1,18 @@ +import { db } from "@/db"; +import * as drizzleDb from "@/db"; +import { eq } from "drizzle-orm"; +import { env } from "@/env.mjs"; +import { logger } from "@/lib/logger"; + +const log = logger.child({ module: "init/onboarding" }); + +export async function markOnboardingDoneIfSkipped() { + if (env.SKIP_ONBOARDING !== "true") return; + + await db + .update(drizzleDb.schemas.setting) + .set({ onboarding: true }) + .where(eq(drizzleDb.schemas.setting.name, "system")); + + log.info("SKIP_ONBOARDING=true: marked onboarding as done."); +} diff --git a/src/utils/init/setting.ts b/src/utils/init/setting.ts index 637f2d48..35cfefef 100644 --- a/src/utils/init/setting.ts +++ b/src/utils/init/setting.ts @@ -1,70 +1,68 @@ -import {db} from "@/db"; -import {env} from "@/env.mjs"; +import { db } from "@/db"; +import { env } from "@/env.mjs"; import * as drizzleDb from "@/db"; -import {eq} from "drizzle-orm"; -import {StorageProviderKind} from "@/features/storages/storages.types"; -import {withUpdatedAt} from "@/db/utils"; - +import { eq } from "drizzle-orm"; +import { StorageProviderKind } from "@/features/storages/types"; +import { withUpdatedAt } from "@/db/utils"; export async function createSettingsIfNotExist() { - await db.transaction(async (tx) => { + await db.transaction(async (tx) => { + const systemSettingsValues = { + name: "system", + smtpPassword: env.SMTP_PASSWORD ?? null, + smtpFrom: env.SMTP_FROM ?? null, + smtpHost: env.SMTP_HOST ?? null, + smtpPort: env.SMTP_PORT ?? null, + smtpUser: env.SMTP_USER ?? null, + smtpSecure: env.SMTP_SECURE ?? false, + }; - const systemSettingsValues = { - name: "system", - smtpPassword: env.SMTP_PASSWORD ?? null, - smtpFrom: env.SMTP_FROM ?? null, - smtpHost: env.SMTP_HOST ?? null, - smtpPort: env.SMTP_PORT ?? null, - smtpUser: env.SMTP_USER ?? null, - smtpSecure: env.SMTP_SECURE ?? false, - }; + const [systemSetting] = await tx + .select() + .from(drizzleDb.schemas.setting) + .where(eq(drizzleDb.schemas.setting.name, "system")) + .limit(1); - const [systemSetting] = await tx - .select() - .from(drizzleDb.schemas.setting) - .where(eq(drizzleDb.schemas.setting.name, "system")) - .limit(1); + const [finalSystemSetting] = systemSetting + ? await tx + .update(drizzleDb.schemas.setting) + .set(systemSettingsValues) + .where(eq(drizzleDb.schemas.setting.name, "system")) + .returning() + : await tx + .insert(drizzleDb.schemas.setting) + .values(systemSettingsValues) + .returning(); - const [finalSystemSetting] = systemSetting - ? await tx - .update(drizzleDb.schemas.setting) - .set(systemSettingsValues) - .where(eq(drizzleDb.schemas.setting.name, "system")) - .returning() - : await tx - .insert(drizzleDb.schemas.setting) - .values(systemSettingsValues) - .returning(); + const localStorageValues = { + provider: "local" as StorageProviderKind, + enabled: true, + name: "System", + config: {}, + }; - const localStorageValues = { - provider: "local" as StorageProviderKind, - enabled: true, - name: "System", - config: {}, - }; + const [existingLocalStorage] = await tx + .select() + .from(drizzleDb.schemas.storageChannel) + .where(eq(drizzleDb.schemas.storageChannel.provider, "local")) + .limit(1); - const [existingLocalStorage] = await tx - .select() - .from(drizzleDb.schemas.storageChannel) - .where(eq(drizzleDb.schemas.storageChannel.provider, "local")) - .limit(1); + const [localStorage] = existingLocalStorage + ? await tx + .update(drizzleDb.schemas.storageChannel) + .set(localStorageValues) + .where(eq(drizzleDb.schemas.storageChannel.provider, "local")) + .returning() + : await tx + .insert(drizzleDb.schemas.storageChannel) + .values(localStorageValues) + .returning(); - const [localStorage] = existingLocalStorage - ? await tx - .update(drizzleDb.schemas.storageChannel) - .set(localStorageValues) - .where(eq(drizzleDb.schemas.storageChannel.provider, "local")) - .returning() - : await tx - .insert(drizzleDb.schemas.storageChannel) - .values(localStorageValues) - .returning(); - - if (!finalSystemSetting.defaultStorageChannelId) { - await tx - .update(drizzleDb.schemas.setting) - .set(withUpdatedAt({ defaultStorageChannelId: localStorage.id })) - .where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id)); - } - }); -} \ No newline at end of file + if (!finalSystemSetting.defaultStorageChannelId) { + await tx + .update(drizzleDb.schemas.setting) + .set(withUpdatedAt({ defaultStorageChannelId: localStorage.id })) + .where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id)); + } + }); +} diff --git a/src/utils/init/user.ts b/src/utils/init/user.ts index d5c653e5..d7564f5c 100644 --- a/src/utils/init/user.ts +++ b/src/utils/init/user.ts @@ -1,71 +1,64 @@ -import {db} from "@/db"; +import { db } from "@/db"; import * as drizzleDb from "@/db"; -import {count, eq} from "drizzle-orm"; -import {logger} from "@/lib/logger"; +import { eq } from "drizzle-orm"; +import { logger } from "@/lib/logger"; import type { SignUpUser } from "@/types/auth"; -import {env} from "@/env.mjs"; -import {createUserDb} from "@/db/services/user"; +import { env } from "@/env.mjs"; +import { createUserDb } from "@/db/services/user"; -const log = logger.child({module: "init/user"}); +const log = logger.child({ module: "init/user" }); export async function createDefaultUser() { + const hasRequiredEnv = + env.AUTH_DEFAULT_USER && + env.AUTH_DEFAULT_PASSWORD && + env.AUTH_DEFAULT_USER_NAME; - const hasRequiredEnv = - env.AUTH_DEFAULT_USER && - env.AUTH_DEFAULT_PASSWORD && - env.AUTH_DEFAULT_USER_NAME; + if (!hasRequiredEnv) { + log.info("Default admin creation skipped: missing environment variables."); + return; + } - if (!hasRequiredEnv) { - log.info( - "Default admin creation skipped: missing environment variables.", - ); - return; - } + log.info("Checking default super admin..."); - log.info("Checking default super admin..."); + const existingSuperAdmin = await db.query.user.findFirst({ + where: eq(drizzleDb.schemas.user.role, "superadmin"), + }); - const existingSuperAdmin = await db.query.user.findFirst({ - where: eq(drizzleDb.schemas.user.role, "superadmin"), + if (existingSuperAdmin) { + log.info( + `CreateDefaultUser skipped: a superadmin already exists (${existingSuperAdmin.email}).`, + ); + return; + } + + const userData: SignUpUser = { + name: env.AUTH_DEFAULT_USER_NAME!, + email: env.AUTH_DEFAULT_USER!, + password: env.AUTH_DEFAULT_PASSWORD!, + theme: "system", + role: "superadmin", + emailVerified: true, + }; + + const newUser = await createUserDb(userData); + + if (newUser) { + log.info(`Default super admin created (${userData.email}).`); + + const defaultOrgSlug = "default"; + const defaultOrg = await db.query.organization.findFirst({ + where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug), }); - if (existingSuperAdmin) { - log.info( - `CreateDefaultUser skipped: a superadmin already exists (${existingSuperAdmin.email}).`, - ); - return; - } - - const userData: SignUpUser = { - name: env.AUTH_DEFAULT_USER_NAME!, - email: env.AUTH_DEFAULT_USER!, - password: env.AUTH_DEFAULT_PASSWORD!, - theme: "system", - role: "superadmin", - emailVerified: true, - }; - - - const newUser = await createUserDb(userData); - - if (newUser) { - log.info(`Default super admin created (${userData.email}).`); - - const defaultOrgSlug = "default"; - const defaultOrg = await db.query.organization.findFirst({ - where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug), - }); - - if (defaultOrg) { - await db.insert(drizzleDb.schemas.member).values({ - userId: newUser.id, - organizationId: defaultOrg.id, - role: "owner", - }); - } else { - log.warn( - "Default organization not found. Cannot assign member.", - ); - } - + if (defaultOrg) { + await db.insert(drizzleDb.schemas.member).values({ + userId: newUser.id, + organizationId: defaultOrg.id, + role: "owner", + }); + } else { + log.warn("Default organization not found. Cannot assign member."); } + } } diff --git a/src/utils/mock-data.ts b/src/utils/mock-data.ts deleted file mode 100644 index a6adf0e5..00000000 --- a/src/utils/mock-data.ts +++ /dev/null @@ -1,115 +0,0 @@ -export const organizations = [ - { - slug: "default", - name: "Default Organization", - createdAt: "2024-01-10T10:00:00.000Z", - projects: [], - }, - { - slug: "tech-corp", - name: "Tech Corp", - createdAt: "2024-01-10T10:00:00.000Z", - projects: [], - }, - { - slug: "design-studio", - name: "Design Studio", - createdAt: "2023-09-15T15:45:00.000Z", - projects: [], - }, -]; - -export const projects = [ - { - slug: "backend-system", - name: "Backend System", - createdAt: "2024-02-20T11:30:00.000Z", - organizationId: "org-1a2b3c4d", - databases: [], - }, - { - slug: "creative-suite", - name: "Creative Suite", - createdAt: "2023-10-10T08:20:00.000Z", - organizationId: "org-2e3f4g5h", - databases: [], - }, -]; - -export const databases = [ - { - name: "Main Production DB", - dbms: "postgresql", - generatedId: "prod-db-1", - description: "Primary database for production environment", - backupPolicy: "daily", - createdAt: "2024-11-01T12:00:00.000Z", - agentId: "agent-1234", - lastContact: "2024-11-28T08:30:00.000Z", - projectId: "proj-789", - backups: [], - restorations: [], - }, - { - name: "Staging DB", - dbms: "mysql", - generatedId: "staging-db-2", - description: "Database for testing and staging environment", - backupPolicy: "weekly", - createdAt: "2024-10-15T09:45:00.000Z", - agentId: "agent-5678", - lastContact: "2024-11-25T10:15:00.000Z", - projectId: null, - backups: [], - restorations: [], - }, - { - name: "Development DB", - dbms: "mongodb", - generatedId: "dev-db-3", - description: null, - backupPolicy: null, - createdAt: "2024-09-20T16:20:00.000Z", - agentId: "agent-9012", - lastContact: null, - projectId: "proj-456", - backups: [], - restorations: [], - }, -]; - -export const backups = [ - { createdAt: "2023-11-27T11:26:54.870914Z", status: "pending" }, - { createdAt: "2023-12-03T11:26:54.870927Z", status: "failed" }, - { createdAt: "2023-12-12T11:26:54.870937Z", status: "success" }, - { createdAt: "2023-11-23T11:26:54.870946Z", status: "processing" }, - { createdAt: "2023-12-09T11:26:54.870955Z", status: "pending" }, - { createdAt: "2023-11-29T11:26:54.870964Z", status: "failed" }, - { createdAt: "2023-12-07T11:26:54.870973Z", status: "success" }, - { createdAt: "2023-11-18T11:26:54.870982Z", status: "processing" }, - { createdAt: "2023-12-01T11:26:54.870991Z", status: "pending" }, - { createdAt: "2023-11-25T11:26:54.871000Z", status: "failed" }, - { createdAt: "2023-11-27T11:26:54.870914Z", status: "pending" }, - { createdAt: "2023-12-03T11:26:54.870927Z", status: "failed" }, - { createdAt: "2023-12-12T11:26:54.870937Z", status: "success" }, - { createdAt: "2023-11-23T11:26:54.870946Z", status: "processing" }, - { createdAt: "2023-12-09T11:26:54.870955Z", status: "pending" }, - { createdAt: "2023-11-29T11:26:54.870964Z", status: "failed" }, - { createdAt: "2023-12-07T11:26:54.870973Z", status: "success" }, - { createdAt: "2023-11-18T11:26:54.870982Z", status: "processing" }, - { createdAt: "2023-12-01T11:26:54.870991Z", status: "pending" }, - { createdAt: "2023-11-25T11:26:54.871000Z", status: "failed" }, -]; - -export const restorations = [ - { backupId: "backup-1", createdAt: "2023-11-27T11:26:54.870914Z", status: "pending" }, - { backupId: "backup-2", createdAt: "2023-12-03T11:26:54.870927Z", status: "failed" }, - { backupId: "backup-3", createdAt: "2023-12-12T11:26:54.870937Z", status: "success" }, - { backupId: "backup-4", createdAt: "2023-11-23T11:26:54.870946Z", status: "processing" }, - { backupId: "backup-5", createdAt: "2023-12-09T11:26:54.870955Z", status: "pending" }, - { backupId: "backup-6", createdAt: "2023-11-29T11:26:54.870964Z", status: "failed" }, - { backupId: "backup-7", createdAt: "2023-12-07T11:26:54.870973Z", status: "success" }, - { backupId: "backup-8", createdAt: "2023-11-18T11:26:54.870982Z", status: "processing" }, - { backupId: "backup-9", createdAt: "2023-12-01T11:26:54.870991Z", status: "pending" }, - { backupId: "backup-10", createdAt: "2023-11-25T11:26:54.871000Z", status: "failed" }, -]; diff --git a/src/utils/name-from-email.ts b/src/utils/name-from-email.ts index 7e65e8bb..0cca0fdb 100644 --- a/src/utils/name-from-email.ts +++ b/src/utils/name-from-email.ts @@ -1,11 +1,11 @@ export function extractNameFromEmail(email: string): string { - const localPart = email.split("@")[0]; - const nameParts = localPart - .replace(/[_\.\-]/g, " ") // Replace underscores, dots, and hyphens with spaces - .split(" ") // Split into parts - .filter(Boolean); // Remove empty strings + const localPart = email.split("@")[0]; + const nameParts = localPart + .replace(/[_\.\-]/g, " ") + .split(" ") + .filter(Boolean); - return nameParts - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) // Capitalize each part - .join(" "); + return nameParts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); } diff --git a/src/utils/os-parser.ts b/src/utils/os-parser.ts deleted file mode 100644 index 7f256bbe..00000000 --- a/src/utils/os-parser.ts +++ /dev/null @@ -1,148 +0,0 @@ -export default function detectOSWithUA(userAgent: string) { - const osList = [ - { - name: "Windows", - keywords: ["Win", "NT", "Windows"], - icon: { - name: "microsoft-windows-icon", - size: { - width: 24, - height: 0, - }, - }, - showText: true, - }, - { - name: "Ubuntu", - keywords: ["Ubuntu"], - icon: { - name: "ubuntu", - size: { - width: 24, - height: 0, - }, - }, - showText: true, - }, - { - name: "iOS", - keywords: ["iOS"], - icon: { - name: "ios", - size: { - width: 24, - height: 0, - }, - }, - showText: false, - }, - { - name: "iPadOS", - keywords: ["iPadOS", "iPad"], - icon: { - name: "ios", - size: { - width: 24, - height: 0, - }, - }, - showText: true, - }, - { - name: "MacOS", - keywords: ["MacOS", "Macintosh", "Mac OS", "Mac OS X"], - icon: { - name: "macos", - size: { - width: 0, - height: 16, - }, - }, - showText: false, - }, - { - name: "Android", - keywords: ["Android"], - icon: { - name: "android-icon", - size: { - width: 24, - height: 0, - }, - }, - showText: true, - }, - { - name: "Linux", - keywords: ["X11", "Linux"], - icon: { - name: "linux-tux", - size: { - width: 24, - height: 0, - }, - }, - showText: true, - }, - { - name: "Playstation 4", - keywords: ["PlayStation 4"], - showText: true, - }, - { - name: "Playstation 5", - keywords: ["PlayStation 5"], - showText: true, - }, - { - name: "Xbox Series X", - keywords: ["Xbox Series X"], - showText: true, - }, - { - name: "Xbox One S", - keywords: ["XBOX_ONE_ED"], - showText: true, - }, - { - name: "Xbox One", - keywords: ["Xbox One"], - showText: true, - }, - { - name: "Nintendo Switch", - keywords: ["Nintendo Switch"], - showText: true, - }, - { - name: "AppleTV", - keywords: ["AppleTV"], - icon: { - name: "apple", - size: { - width: 24, - height: 0, - }, - }, - showText: true, - }, - ]; - - for (let os of osList) { - if (os.keywords.some((keyword) => userAgent.includes(keyword))) { - return os; - } - } - - return { - name: "Unknown OS", - icon: { - name: "unknown", - size: { - width: 24, - height: 0, - }, - }, - showText: true, - }; -} diff --git a/src/utils/resolve-avatar-url.ts b/src/utils/resolve-avatar-url.ts new file mode 100644 index 00000000..95ecc5fd --- /dev/null +++ b/src/utils/resolve-avatar-url.ts @@ -0,0 +1,12 @@ +import { createHash } from "crypto"; +import type { User } from "@/db/schema/02_user"; + +export function resolveAvatarUrl( + user: Pick, +): string | undefined { + if (user.image) return user.image; + const hash = createHash("md5") + .update((user.email ?? "").trim().toLowerCase()) + .digest("hex"); + return `https://www.gravatar.com/avatar/${hash}?s=200&d=404`; +} diff --git a/src/utils/rsa-keys.ts b/src/utils/rsa-keys.ts index 12065ba1..8023ead8 100644 --- a/src/utils/rsa-keys.ts +++ b/src/utils/rsa-keys.ts @@ -1,14 +1,12 @@ -import {promises as fs} from 'fs'; -import path from 'path'; -import {generateKeyPair} from 'crypto'; -import {promisify} from 'util'; -import {randomBytes} from 'crypto'; -import {env} from "@/env.mjs"; -import {logger} from "@/lib/logger"; - -const log = logger.child({module: "rsa-keys"}); - +import { promises as fs } from "fs"; +import path from "path"; +import { generateKeyPair } from "crypto"; +import { promisify } from "util"; +import { randomBytes } from "crypto"; +import { env } from "@/env.mjs"; +import { logger } from "@/lib/logger"; +const log = logger.child({ module: "rsa-keys" }); const generateKeyPairAsync = promisify(generateKeyPair); @@ -19,33 +17,33 @@ const generateKeyPairAsync = promisify(generateKeyPair); * @param {string} [dir] path to directory * @returns {Promise<{privateKeyPath:string, publicKeyPath:string}>} */ -export async function generateRSAKeys(dir = path.join(env.PRIVATE_PATH!, '/keys')) { - await fs.mkdir(dir, {recursive: true}); +export async function generateRSAKeys( + dir = path.join(env.PRIVATE_PATH!, "/keys"), +) { + await fs.mkdir(dir, { recursive: true }); - const privateKeyPath = path.join(dir, 'server_private.pem'); - const publicKeyPath = path.join(dir, 'server_public.pem'); + const privateKeyPath = path.join(dir, "server_private.pem"); + const publicKeyPath = path.join(dir, "server_public.pem"); - try { - await fs.access(privateKeyPath); - await fs.access(publicKeyPath); - log.info('RSA keys already exist. Skipping generation.'); - return {privateKeyPath, publicKeyPath}; - } catch { - } + try { + await fs.access(privateKeyPath); + await fs.access(publicKeyPath); + log.info("RSA keys already exist. Skipping generation."); + return { privateKeyPath, publicKeyPath }; + } catch {} - const {publicKey, privateKey} = await generateKeyPairAsync('rsa', { - modulusLength: 2048, - publicKeyEncoding: {type: 'pkcs1', format: 'pem'}, - privateKeyEncoding: {type: 'pkcs1', format: 'pem'}, - }); + const { publicKey, privateKey } = await generateKeyPairAsync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "pem" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); - await fs.writeFile(privateKeyPath, privateKey, {mode: 0o600}); - await fs.writeFile(publicKeyPath, publicKey, {mode: 0o644}); + await fs.writeFile(privateKeyPath, privateKey, { mode: 0o600 }); + await fs.writeFile(publicKeyPath, publicKey, { mode: 0o644 }); - return {privateKeyPath, publicKeyPath}; + return { privateKeyPath, publicKeyPath }; } - /** * Generate a 256-bit AES master key for AES-256-GCM. * - Skips generation if the file already exists. @@ -53,23 +51,21 @@ export async function generateRSAKeys(dir = path.join(env.PRIVATE_PATH!, '/keys' * @param {string} [filePath] Path to store the key * @returns {Promise} The master key */ -export async function getOrCreateMasterKey(filePath = path.join(env.PRIVATE_PATH!, '/keys', 'master_key.bin')) { +export async function getOrCreateMasterKey( + filePath = path.join(env.PRIVATE_PATH!, "/keys", "master_key.bin"), +) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.mkdir(path.dirname(filePath), {recursive: true}); - - try { - const existing = await fs.readFile(filePath); - log.info('Master key already exists. Skipping generation.'); - return existing; - } catch { - // File does not exist, generate - } - - const key = randomBytes(32); // 256-bit key - - await fs.writeFile(filePath, key, {mode: 0o600}); + try { + const existing = await fs.readFile(filePath); log.info("Master key already exists. Skipping generation."); + return existing; + } catch {} - return key; + const key = randomBytes(32); + + await fs.writeFile(filePath, key, { mode: 0o600 }); + log.info("Master key already exists. Skipping generation."); + + return key; } - diff --git a/src/utils/slugify.ts b/src/utils/slugify.ts index ac669d60..24adbb7a 100644 --- a/src/utils/slugify.ts +++ b/src/utils/slugify.ts @@ -1,14 +1,14 @@ export const slugify = (text: string) => { - return text - .toString() - .normalize('NFKD') // Normalize accents - .replace(/[\u0300-\u036f]/g, '') // Remove diacritics - .toLowerCase() - .trim() - .replace(/\_/g, '-') // _ → dash - .replace(/\s+/g, '-') // spaces → dash - .replace(/[^\w\-]+/g, '') // Remove non-word chars - .replace(/\-\-+/g, '-') // multiple dashes → one - .replace(/^-+/, '') // Remove leading dash - .replace(/-+$/, ''); // Remove trailing dash -}; \ No newline at end of file + return text + .toString() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .trim() + .replace(/\_/g, "-") + .replace(/\s+/g, "-") + .replace(/[^\w\-]+/g, "") + .replace(/\-\-+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, ""); +}; diff --git a/src/utils/text.ts b/src/utils/text.ts index dc695e33..a61be2eb 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -1,69 +1,67 @@ export function truncateWords(text: string, wordLimit: number = 10): string { - if (!text) return ""; - const words = text.trim().split(/\s+/); - if (words.length <= wordLimit) return text; - return words.slice(0, wordLimit).join(" ") + "…"; + if (!text) return ""; + const words = text.trim().split(/\s+/); + if (words.length <= wordLimit) return text; + return words.slice(0, wordLimit).join(" ") + "…"; } export function capitalizeFirstLetter(text: string): string { - return text ? text.charAt(0).toUpperCase() + text.slice(1) : ""; + return text ? text.charAt(0).toUpperCase() + text.slice(1) : ""; } export function isUUID(str: string) { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(str); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + str, + ); } export function isImportedFilename(name: string): boolean { - return name.startsWith("imported_"); + return name.startsWith("imported_"); } export function formatBytes(bytes: number | null, decimals = 2): string { - if (!bytes) return "N/A"; - if (bytes === 0) return "0 Bytes"; - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; + if (!bytes) return "N/A"; + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; } export function formatDuration(ms: number): string { - if (ms == null || Number.isNaN(ms)) return "0 ms"; + if (ms == null || Number.isNaN(ms)) return "0 ms"; - const totalMs = Math.max(0, Math.floor(ms)); + const totalMs = Math.max(0, Math.floor(ms)); - if (totalMs < 1000) { - return `${totalMs} ms`; - } + if (totalMs < 1000) { + return `${totalMs} ms`; + } - const totalSeconds = Math.floor(totalMs / 1000); - const seconds = totalSeconds % 60; + const totalSeconds = Math.floor(totalMs / 1000); + const seconds = totalSeconds % 60; - if (totalSeconds < 60) { - return `${totalSeconds} s`; - } + if (totalSeconds < 60) { + return `${totalSeconds} s`; + } - const totalMinutes = Math.floor(totalSeconds / 60); - const minutes = totalMinutes % 60; + const totalMinutes = Math.floor(totalSeconds / 60); + const minutes = totalMinutes % 60; - if (totalMinutes < 60) { - return seconds > 0 - ? `${totalMinutes} min ${seconds} s` - : `${totalMinutes} min`; - } + if (totalMinutes < 60) { + return seconds > 0 + ? `${totalMinutes} min ${seconds} s` + : `${totalMinutes} min`; + } - const totalHours = Math.floor(totalMinutes / 60); - const hours = totalHours % 24; + const totalHours = Math.floor(totalMinutes / 60); + const hours = totalHours % 24; - if (totalHours < 24) { - return minutes > 0 - ? `${totalHours} h ${minutes} min` - : `${totalHours} h`; - } + if (totalHours < 24) { + return minutes > 0 ? `${totalHours} h ${minutes} min` : `${totalHours} h`; + } - const days = Math.floor(totalHours / 24); + const days = Math.floor(totalHours / 24); - return hours > 0 - ? `${days} d ${hours} h` - : `${days} d`; -} \ No newline at end of file + return hours > 0 ? `${days} d ${hours} h` : `${days} d`; +} diff --git a/src/utils/verify-uuid.ts b/src/utils/verify-uuid.ts deleted file mode 100644 index fd1cfd8f..00000000 --- a/src/utils/verify-uuid.ts +++ /dev/null @@ -1,5 +0,0 @@ -const uuidv4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export function isUuidv4(value: string): value is string { - return uuidv4Regex.test(value); -}