create-ej-app 26.0.0 → 27.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -226
- package/cli.ts +79 -29
- package/package.json +2 -2
- package/template/api/.vscode/settings.json +1 -1
- package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
- package/template/api/CLAUDE.md +1 -0
- package/template/api/package.json +10 -10
- package/template/nextjs/.dockerignore +1 -0
- package/template/nextjs/AGENTS.md +260 -0
- package/template/nextjs/CLAUDE.md +1 -0
- package/template/nextjs/README.md +109 -0
- package/template/nextjs/bunfig.toml +3 -0
- package/template/nextjs/eslint.config.mjs +6 -5
- package/template/nextjs/gitignore +6 -1
- package/template/nextjs/next.config.ts +8 -1
- package/template/nextjs/package.json +46 -39
- package/template/nextjs/public/logo-dark.svg +4 -0
- package/template/nextjs/public/logo.svg +4 -0
- package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
- package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
- package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
- package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
- package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
- package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
- package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
- package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
- package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
- package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
- package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
- package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
- package/template/nextjs/src/app/error.tsx +37 -0
- package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/index.ts +60 -0
- package/template/nextjs/src/app/global-error.tsx +38 -0
- package/template/nextjs/src/app/globals.css +29 -0
- package/template/nextjs/src/app/layout.tsx +45 -18
- package/template/nextjs/src/app/not-found.tsx +25 -3
- package/template/nextjs/src/app/robots.ts +29 -0
- package/template/nextjs/src/app/sitemap.ts +21 -0
- package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
- package/template/nextjs/src/components/dashboard-header.tsx +70 -0
- package/template/nextjs/src/components/data-table/enum.ts +5 -0
- package/template/nextjs/src/components/data-table/features.ts +49 -0
- package/template/nextjs/src/components/data-table/index.tsx +961 -0
- package/template/nextjs/src/components/data-table/types.ts +76 -0
- package/template/nextjs/src/components/error.tsx +71 -0
- package/template/nextjs/src/components/loader.tsx +18 -2
- package/template/nextjs/src/components/logo.tsx +49 -0
- package/template/nextjs/src/components/navigation-search.tsx +140 -0
- package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
- package/template/nextjs/src/components/page-heading.tsx +38 -0
- package/template/nextjs/src/components/react-query-provider.tsx +41 -2
- package/template/nextjs/src/components/route-error.tsx +55 -0
- package/template/nextjs/src/components/screen-size.tsx +36 -0
- package/template/nextjs/src/components/site-footer.tsx +20 -4
- package/template/nextjs/src/components/theme-provider.tsx +48 -0
- package/template/nextjs/src/components/theme-toggle.tsx +58 -5
- package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
- package/template/nextjs/src/components/ui/avatar.tsx +112 -0
- package/template/nextjs/src/components/ui/badge.tsx +54 -0
- package/template/nextjs/src/components/ui/button.tsx +3 -1
- package/template/nextjs/src/components/ui/card.tsx +4 -4
- package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
- package/template/nextjs/src/components/ui/command.tsx +192 -0
- package/template/nextjs/src/components/ui/dialog.tsx +164 -0
- package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/nextjs/src/components/ui/input-group.tsx +156 -0
- package/template/nextjs/src/components/ui/select.tsx +195 -0
- package/template/nextjs/src/components/ui/sheet.tsx +143 -0
- package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
- package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
- package/template/nextjs/src/components/ui/table.tsx +116 -0
- package/template/nextjs/src/components/ui/textarea.tsx +18 -0
- package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
- package/template/nextjs/src/components/user-menu.tsx +206 -0
- package/template/nextjs/src/db/common.ts +10 -0
- package/template/nextjs/src/db/index.ts +16 -6
- package/template/nextjs/src/db/relations.ts +21 -0
- package/template/nextjs/src/db/schema.ts +85 -46
- package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
- package/template/nextjs/src/emails/magic-link.tsx +15 -13
- package/template/nextjs/src/emails/test.tsx +11 -9
- package/template/nextjs/src/emails/welcome.tsx +12 -9
- package/template/nextjs/src/env.ts +1 -1
- package/template/nextjs/src/hooks/use-mobile.ts +14 -12
- package/template/nextjs/src/instrumentation.ts +20 -2
- package/template/nextjs/src/lib/actions.ts +55 -0
- package/template/nextjs/src/lib/admin-navigation.ts +43 -0
- package/template/nextjs/src/lib/auth-client.ts +20 -9
- package/template/nextjs/src/lib/auth-permissions.ts +61 -0
- package/template/nextjs/src/lib/auth.ts +33 -3
- package/template/nextjs/src/lib/callback-url.ts +28 -0
- package/template/nextjs/src/lib/constants.ts +21 -0
- package/template/nextjs/src/lib/delay.ts +10 -0
- package/template/nextjs/src/lib/email-service.ts +21 -0
- package/template/nextjs/src/lib/errors.ts +74 -0
- package/template/nextjs/src/lib/format-currency.ts +22 -24
- package/template/nextjs/src/lib/format-date.ts +69 -6
- package/template/nextjs/src/lib/nodemailer.ts +27 -3
- package/template/nextjs/src/lib/otel.ts +92 -2
- package/template/nextjs/src/lib/safe-action-client.ts +60 -9
- package/template/nextjs/src/lib/safe-action.ts +152 -16
- package/template/nextjs/src/lib/search.ts +16 -0
- package/template/nextjs/src/lib/seo.ts +7 -0
- package/template/nextjs/src/lib/siteConfig.ts +17 -1
- package/template/nextjs/src/lib/toast-message.ts +87 -1
- package/template/nextjs/src/lib/validations.ts +30 -0
- package/template/nextjs/src/proxy.ts +48 -0
- package/template/nextjs/src/scripts/reset-db.ts +22 -0
- package/template/nextjs/src/scripts/seed.ts +59 -8
- package/template/nextjs/src/scripts/test-mail.ts +28 -20
- package/template/nextjs/tests/admin/users.test.ts +64 -0
- package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
- package/template/nextjs/tests/db/seed.test.ts +49 -0
- package/template/nextjs/tests/helpers/action.ts +71 -0
- package/template/nextjs/tests/helpers/fixtures.ts +78 -0
- package/template/nextjs/tests/helpers/index.ts +2 -0
- package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
- package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/nextjs/tests/lib/errors.test.ts +10 -0
- package/template/nextjs/tests/lib/search.test.ts +23 -0
- package/template/nextjs/tests/setup/pglite-db.ts +63 -0
- package/template/nextjs/tests/setup/preload.ts +95 -0
- package/template/nextjs/tsconfig.json +1 -0
- package/template/saas-starter/.dockerignore +1 -0
- package/template/saas-starter/.env.example +15 -15
- package/template/saas-starter/AGENTS.md +302 -0
- package/template/saas-starter/CLAUDE.md +1 -0
- package/template/saas-starter/README.md +124 -2
- package/template/saas-starter/bunfig.toml +3 -0
- package/template/saas-starter/eslint.config.mjs +6 -5
- package/template/saas-starter/gitignore +6 -1
- package/template/saas-starter/next.config.ts +19 -1
- package/template/saas-starter/package.json +49 -44
- package/template/saas-starter/public/logo-dark.svg +4 -0
- package/template/saas-starter/public/logo.svg +4 -0
- package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
- package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
- package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
- package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
- package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
- package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
- package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
- package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
- package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
- package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
- package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
- package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
- package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
- package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
- package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
- package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
- package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
- package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
- package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
- package/template/saas-starter/src/app/actions/uploads.ts +36 -0
- package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
- package/template/saas-starter/src/app/error.tsx +37 -0
- package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/index.ts +60 -0
- package/template/saas-starter/src/app/global-error.tsx +38 -0
- package/template/saas-starter/src/app/globals.css +29 -0
- package/template/saas-starter/src/app/layout.tsx +43 -19
- package/template/saas-starter/src/app/not-found.tsx +25 -3
- package/template/saas-starter/src/app/robots.ts +14 -10
- package/template/saas-starter/src/app/sitemap.ts +2 -1
- package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
- package/template/saas-starter/src/components/data-table/features.ts +49 -0
- package/template/saas-starter/src/components/data-table/index.tsx +65 -64
- package/template/saas-starter/src/components/data-table/types.ts +5 -4
- package/template/saas-starter/src/components/error.tsx +45 -0
- package/template/saas-starter/src/components/file-upload.tsx +65 -26
- package/template/saas-starter/src/components/loader.tsx +18 -2
- package/template/saas-starter/src/components/logo.tsx +49 -0
- package/template/saas-starter/src/components/navigation-search.tsx +2 -2
- package/template/saas-starter/src/components/page-heading.tsx +6 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
- package/template/saas-starter/src/components/route-error.tsx +55 -0
- package/template/saas-starter/src/components/screen-size.tsx +36 -0
- package/template/saas-starter/src/components/site-footer.tsx +20 -4
- package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
- package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
- package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
- package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
- package/template/saas-starter/src/components/theme-provider.tsx +48 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
- package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
- package/template/saas-starter/src/components/ui/badge.tsx +6 -1
- package/template/saas-starter/src/components/ui/button.tsx +2 -0
- package/template/saas-starter/src/components/ui/command.tsx +1 -1
- package/template/saas-starter/src/components/user-menu.tsx +147 -24
- package/template/saas-starter/src/db/common.ts +33 -1
- package/template/saas-starter/src/db/enums.ts +24 -0
- package/template/saas-starter/src/db/index.ts +14 -5
- package/template/saas-starter/src/db/relations.ts +40 -3
- package/template/saas-starter/src/db/schema.ts +186 -35
- package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
- package/template/saas-starter/src/emails/invitation.tsx +34 -21
- package/template/saas-starter/src/emails/magic-link.tsx +15 -13
- package/template/saas-starter/src/emails/test.tsx +11 -9
- package/template/saas-starter/src/emails/welcome.tsx +12 -9
- package/template/saas-starter/src/env.ts +1 -1
- package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
- package/template/saas-starter/src/instrumentation.ts +20 -2
- package/template/saas-starter/src/lib/actions.ts +34 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
- package/template/saas-starter/src/lib/app-navigation.ts +45 -41
- package/template/saas-starter/src/lib/auth-client.ts +8 -3
- package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
- package/template/saas-starter/src/lib/auth.ts +122 -21
- package/template/saas-starter/src/lib/billing.ts +59 -24
- package/template/saas-starter/src/lib/callback-url.ts +28 -0
- package/template/saas-starter/src/lib/constants.ts +78 -9
- package/template/saas-starter/src/lib/delay.ts +10 -0
- package/template/saas-starter/src/lib/email-service.ts +30 -1
- package/template/saas-starter/src/lib/errors.ts +74 -0
- package/template/saas-starter/src/lib/format-currency.ts +22 -24
- package/template/saas-starter/src/lib/format-date.ts +69 -6
- package/template/saas-starter/src/lib/image-url.ts +54 -0
- package/template/saas-starter/src/lib/nodemailer.ts +27 -3
- package/template/saas-starter/src/lib/organization-access.ts +56 -76
- package/template/saas-starter/src/lib/otel.ts +92 -2
- package/template/saas-starter/src/lib/plan-limits.ts +37 -0
- package/template/saas-starter/src/lib/plans.ts +40 -13
- package/template/saas-starter/src/lib/rbac.ts +44 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
- package/template/saas-starter/src/lib/safe-action.ts +137 -17
- package/template/saas-starter/src/lib/search.ts +16 -0
- package/template/saas-starter/src/lib/seo.ts +7 -0
- package/template/saas-starter/src/lib/siteConfig.ts +17 -1
- package/template/saas-starter/src/lib/storage.ts +176 -46
- package/template/saas-starter/src/lib/stripe.ts +4 -1
- package/template/saas-starter/src/lib/toast-message.ts +87 -1
- package/template/saas-starter/src/lib/validations.ts +85 -2
- package/template/saas-starter/src/proxy.ts +48 -0
- package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
- package/template/saas-starter/src/scripts/seed.ts +82 -8
- package/template/saas-starter/src/scripts/setup-db.ts +72 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
- package/template/saas-starter/src/scripts/test-mail.ts +28 -20
- package/template/saas-starter/tests/admin/users.test.ts +64 -0
- package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
- package/template/saas-starter/tests/db/seed.test.ts +57 -0
- package/template/saas-starter/tests/helpers/action.ts +71 -0
- package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
- package/template/saas-starter/tests/helpers/index.ts +2 -0
- package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
- package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/saas-starter/tests/lib/errors.test.ts +10 -0
- package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
- package/template/saas-starter/tests/lib/search.test.ts +23 -0
- package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
- package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
- package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
- package/template/saas-starter/tests/setup/preload.ts +108 -0
- package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
- package/template/saas-starter/tsconfig.json +1 -0
- package/cli.js +0 -147
- package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
- package/template/nextjs/src/app/(app)/layout.tsx +0 -20
- package/template/nextjs/terraform/artifact-registry.tf +0 -5
- package/template/nextjs/terraform/backend-service.tf +0 -13
- package/template/nextjs/terraform/cleanup.sh +0 -9
- package/template/nextjs/terraform/cloudbuild.tf +0 -70
- package/template/nextjs/terraform/cloudrun.tf +0 -68
- package/template/nextjs/terraform/database.tf +0 -60
- package/template/nextjs/terraform/forwarding-rule.tf +0 -43
- package/template/nextjs/terraform/gitignore +0 -4
- package/template/nextjs/terraform/ip-address.tf +0 -11
- package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
- package/template/nextjs/terraform/network.tf +0 -21
- package/template/nextjs/terraform/pre.sh +0 -3
- package/template/nextjs/terraform/providers.tf +0 -18
- package/template/nextjs/terraform/secret.txt +0 -11
- package/template/nextjs/terraform/setup.sh +0 -25
- package/template/nextjs/terraform/ssl-certs.tf +0 -32
- package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
- package/template/nextjs/terraform/target-https-proxy.tf +0 -14
- package/template/nextjs/terraform/terraform.tfvars +0 -4
- package/template/nextjs/terraform/url-map.tf +0 -23
- package/template/nextjs/terraform/variables.tf +0 -21
- package/template/saas-starter/next-env.d.ts +0 -6
- package/template/saas-starter/src/actions/uploads.ts +0 -28
- package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
- package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
- package/template/saas-starter/src/app/proxy.ts +0 -28
- package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
- package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
|
@@ -1,30 +1,132 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
1
3
|
import { createSafeActionClient } from 'next-safe-action'
|
|
2
4
|
import { getAuthSession } from './auth'
|
|
3
|
-
import {
|
|
5
|
+
import { AppError, AppErrorCode, isAppError } from './errors'
|
|
6
|
+
import { flattenForWideEvent, recordError, recordWideEvent } from './otel'
|
|
7
|
+
|
|
8
|
+
/** Shown for every unexpected error; details stay in telemetry. */
|
|
9
|
+
export const UNEXPECTED_ERROR_MESSAGE =
|
|
10
|
+
'The request did not finish. Try again in a few minutes.'
|
|
4
11
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
12
|
+
type ActionServerArgs = {
|
|
13
|
+
parsedInput?: unknown
|
|
14
|
+
clientInput?: unknown
|
|
15
|
+
[key: string]: unknown
|
|
16
|
+
}
|
|
8
17
|
|
|
9
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Wrap a safe-action server function with wide-event telemetry.
|
|
20
|
+
*
|
|
21
|
+
* Records `action.name`, flattened `parsedInput` (or `clientInput`), and on
|
|
22
|
+
* success `outcome: 'success'` plus `duration_ms`. Failures keep error fields
|
|
23
|
+
* from `handleServerError`; duration is still recorded.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* export const myAction = authActionClient
|
|
27
|
+
* .inputSchema(schema)
|
|
28
|
+
* .action(
|
|
29
|
+
* withWideEvent('myAction', async ({ parsedInput, ctx }) => {
|
|
30
|
+
* // business logic only
|
|
31
|
+
* })
|
|
32
|
+
* )
|
|
33
|
+
*/
|
|
34
|
+
export function withWideEvent<TArgs extends ActionServerArgs, TResult>(
|
|
35
|
+
actionName: string,
|
|
36
|
+
serverCode: (args: TArgs) => Promise<TResult>
|
|
37
|
+
): (args: TArgs) => Promise<TResult> {
|
|
38
|
+
return async args => {
|
|
39
|
+
const startedAt = Date.now()
|
|
40
|
+
const input = args.parsedInput ?? args.clientInput
|
|
41
|
+
|
|
42
|
+
recordWideEvent({
|
|
43
|
+
'action.name': actionName,
|
|
44
|
+
...flattenForWideEvent(input, 'input')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const result = await serverCode(args)
|
|
49
|
+
recordWideEvent({
|
|
50
|
+
outcome: 'success',
|
|
51
|
+
duration_ms: Date.now() - startedAt
|
|
52
|
+
})
|
|
53
|
+
return result
|
|
54
|
+
} catch (error) {
|
|
10
55
|
recordWideEvent({
|
|
11
|
-
'
|
|
12
|
-
|
|
13
|
-
'session.id': authSession.session.id
|
|
56
|
+
outcome: 'error',
|
|
57
|
+
duration_ms: Date.now() - startedAt
|
|
14
58
|
})
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
15
63
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Configured safe action client with authentication middleware
|
|
66
|
+
* This client wraps server actions with:
|
|
67
|
+
* - Automatic input/output validation using Zod
|
|
68
|
+
* - Type-safe error handling ({@link AppError} messages reach the client)
|
|
69
|
+
* - Server-side authentication injection
|
|
70
|
+
*
|
|
71
|
+
* All server actions using this client automatically receive the current auth session
|
|
72
|
+
* in the context, so protected actions can access user information.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* export const myAction = actionClient
|
|
76
|
+
* .inputSchema(z.object({ name: z.string() }))
|
|
77
|
+
* .action(async ({ parsedInput: { name }, ctx }) => {
|
|
78
|
+
* const userId = ctx.user?.id // Auth injected automatically
|
|
79
|
+
* return { success: true }
|
|
80
|
+
* })
|
|
81
|
+
*/
|
|
82
|
+
export const actionClient = createSafeActionClient({
|
|
83
|
+
handleServerError(e) {
|
|
84
|
+
if (isAppError(e)) {
|
|
85
|
+
recordWideEvent({
|
|
86
|
+
'error.name': e.name,
|
|
87
|
+
'error.code': e.code,
|
|
88
|
+
'error.message': e.message,
|
|
89
|
+
'error.expected': true,
|
|
90
|
+
outcome: 'error'
|
|
91
|
+
})
|
|
92
|
+
return e.message
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
recordError(e)
|
|
96
|
+
recordWideEvent({
|
|
97
|
+
'error.name': e.name,
|
|
98
|
+
'error.expected': false,
|
|
99
|
+
'error.message': e.message,
|
|
100
|
+
outcome: 'error'
|
|
21
101
|
})
|
|
102
|
+
return UNEXPECTED_ERROR_MESSAGE
|
|
22
103
|
}
|
|
23
|
-
)
|
|
104
|
+
}).use(async ({ next, ctx }) => {
|
|
105
|
+
// Retrieve current authentication session
|
|
106
|
+
const authSession = await getAuthSession()
|
|
107
|
+
|
|
108
|
+
// Record user information in telemetry for all actions, if available
|
|
109
|
+
if (authSession) {
|
|
110
|
+
recordWideEvent({
|
|
111
|
+
'user.id': authSession.user.id,
|
|
112
|
+
'user.admin': authSession.isAdmin,
|
|
113
|
+
'user.superAdmin': authSession.isSuperAdmin,
|
|
114
|
+
'session.id': authSession.session.id
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Inject auth session into action context for all downstream actions
|
|
119
|
+
return next({
|
|
120
|
+
ctx: {
|
|
121
|
+
...ctx,
|
|
122
|
+
...authSession
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
})
|
|
24
126
|
|
|
25
127
|
export const authActionClient = actionClient.use(async ({ ctx, next }) => {
|
|
26
128
|
if (!ctx?.user || !ctx?.session) {
|
|
27
|
-
throw new
|
|
129
|
+
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Sign in to continue.')
|
|
28
130
|
}
|
|
29
131
|
|
|
30
132
|
return next({
|
|
@@ -36,9 +138,18 @@ export const authActionClient = actionClient.use(async ({ ctx, next }) => {
|
|
|
36
138
|
})
|
|
37
139
|
})
|
|
38
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Action client for the admin panel.
|
|
143
|
+
*
|
|
144
|
+
* Allows both `admin` and `superadmin` roles. Use for actions that any
|
|
145
|
+
* admin-panel user may run (support tickets, feedback, and contact leads).
|
|
146
|
+
*/
|
|
39
147
|
export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
|
|
40
148
|
if (!ctx.isAdmin) {
|
|
41
|
-
throw new
|
|
149
|
+
throw new AppError(
|
|
150
|
+
AppErrorCode.FORBIDDEN,
|
|
151
|
+
'You do not have permission to do this.'
|
|
152
|
+
)
|
|
42
153
|
}
|
|
43
154
|
|
|
44
155
|
return next({
|
|
@@ -50,10 +161,19 @@ export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
|
|
|
50
161
|
})
|
|
51
162
|
})
|
|
52
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Action client restricted to the `superadmin` role.
|
|
166
|
+
*
|
|
167
|
+
* Use for privileged operations, such as user management, role changes, and
|
|
168
|
+
* impersonation, that `admin` users cannot perform.
|
|
169
|
+
*/
|
|
53
170
|
export const superAdminActionClient = adminActionClient.use(
|
|
54
171
|
async ({ ctx, next }) => {
|
|
55
172
|
if (!ctx.isSuperAdmin) {
|
|
56
|
-
throw new
|
|
173
|
+
throw new AppError(
|
|
174
|
+
AppErrorCode.FORBIDDEN,
|
|
175
|
+
'You do not have permission to do this.'
|
|
176
|
+
)
|
|
57
177
|
}
|
|
58
178
|
|
|
59
179
|
return next({
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape characters that are special in SQL LIKE / ILIKE patterns.
|
|
3
|
+
*
|
|
4
|
+
* Postgres uses `\` as the default escape character. Without escaping, a
|
|
5
|
+
* user-typed `%` or `_` becomes a wildcard and can force full-table scans.
|
|
6
|
+
*/
|
|
7
|
+
export function escapeLikePattern(value: string): string {
|
|
8
|
+
return value.replace(/[\\%_]/g, char => `\\${char}`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a case-insensitive contains pattern: `%value%` with wildcards escaped.
|
|
13
|
+
*/
|
|
14
|
+
export function likeContains(value: string): string {
|
|
15
|
+
return `%${escapeLikePattern(value)}%`
|
|
16
|
+
}
|
|
@@ -1,4 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Brand identity and site-wide copy.
|
|
3
|
+
*
|
|
4
|
+
* Layouts, emails, legal pages, and metadata read from here. Never hardcode
|
|
5
|
+
* the product name anywhere else.
|
|
6
|
+
*
|
|
7
|
+
* @module lib/siteConfig
|
|
8
|
+
*/
|
|
1
9
|
export const siteConfig = {
|
|
2
10
|
name: 'Project Name',
|
|
3
|
-
description: 'Project Description'
|
|
11
|
+
description: 'Project Description',
|
|
12
|
+
logo: '/logo.svg',
|
|
13
|
+
logoDark: '/logo-dark.svg',
|
|
14
|
+
contact: {
|
|
15
|
+
email: 'hello@example.com'
|
|
16
|
+
},
|
|
17
|
+
legal: {
|
|
18
|
+
lastUpdated: 'September 25, 2026'
|
|
19
|
+
}
|
|
4
20
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
DeleteObjectCommand,
|
|
3
5
|
PutObjectCommand,
|
|
@@ -11,27 +13,29 @@ import { fileUploadsTable } from '~/db/schema'
|
|
|
11
13
|
import { env } from '~/env'
|
|
12
14
|
import {
|
|
13
15
|
PRESIGNED_URL_TTL_SECONDS,
|
|
14
|
-
|
|
16
|
+
R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
|
|
17
|
+
TEMP_CLEANUP_THRESHOLD_MS,
|
|
18
|
+
type R2UploadMimeType
|
|
15
19
|
} from './constants'
|
|
20
|
+
import { AppError, AppErrorCode } from './errors'
|
|
21
|
+
import {
|
|
22
|
+
canPersistImageValue,
|
|
23
|
+
isDataImageUrl,
|
|
24
|
+
isR2ObjectKey
|
|
25
|
+
} from './image-url'
|
|
16
26
|
|
|
17
27
|
/*
|
|
18
|
-
R2
|
|
28
|
+
The R2 bucket needs this CORS policy for browser uploads:
|
|
19
29
|
[
|
|
20
30
|
{
|
|
21
|
-
"AllowedOrigins": [
|
|
22
|
-
|
|
23
|
-
]
|
|
24
|
-
"AllowedMethods": [
|
|
25
|
-
"PUT"
|
|
26
|
-
],
|
|
27
|
-
"AllowedHeaders": [
|
|
28
|
-
"content-type",
|
|
29
|
-
"content-length"
|
|
30
|
-
]
|
|
31
|
+
"AllowedOrigins": ["http://localhost:3000"],
|
|
32
|
+
"AllowedMethods": ["PUT"],
|
|
33
|
+
"AllowedHeaders": ["content-type", "content-length"]
|
|
31
34
|
}
|
|
32
35
|
]
|
|
33
36
|
*/
|
|
34
37
|
|
|
38
|
+
/** True when all five R2 env vars are set. When false, uploads fall back to base64 data URLs. */
|
|
35
39
|
export function isR2Configured(): boolean {
|
|
36
40
|
return !!(
|
|
37
41
|
env.R2_ACCOUNT_ID &&
|
|
@@ -44,12 +48,17 @@ export function isR2Configured(): boolean {
|
|
|
44
48
|
|
|
45
49
|
let _r2Client: S3Client | null = null
|
|
46
50
|
|
|
51
|
+
/**
|
|
52
|
+
* `requestChecksumCalculation: 'WHEN_REQUIRED'` avoids signing
|
|
53
|
+
* `x-amz-checksum-crc32=AAAAAA==` (empty body) into presigned PUTs, which
|
|
54
|
+
* breaks non-empty uploads on backends that validate the header.
|
|
55
|
+
*/
|
|
47
56
|
function getR2Client(): S3Client {
|
|
48
57
|
if (_r2Client) return _r2Client
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
throw new Error('R2 storage is not configured')
|
|
59
|
+
if (!isR2Configured()) {
|
|
60
|
+
// A configuration bug, not a customer error: masked and recorded.
|
|
61
|
+
throw new Error('R2 storage is not configured.')
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
_r2Client = new S3Client({
|
|
@@ -58,52 +67,94 @@ function getR2Client(): S3Client {
|
|
|
58
67
|
credentials: {
|
|
59
68
|
accessKeyId: env.R2_ACCESS_KEY_ID!,
|
|
60
69
|
secretAccessKey: env.R2_SECRET_ACCESS_KEY!
|
|
61
|
-
}
|
|
70
|
+
},
|
|
71
|
+
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
72
|
+
responseChecksumValidation: 'WHEN_REQUIRED'
|
|
62
73
|
})
|
|
63
74
|
|
|
64
75
|
return _r2Client
|
|
65
76
|
}
|
|
66
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Throws unless `key` sits under `uploads/organizations/<organizationId>/` and
|
|
80
|
+
* has a `file_uploads` row recorded for that same organization.
|
|
81
|
+
*
|
|
82
|
+
* Call this on any R2 key that comes from user input before it is persisted,
|
|
83
|
+
* confirmed, or deleted, so a request can never reference another tenant's
|
|
84
|
+
* object.
|
|
85
|
+
*/
|
|
86
|
+
export async function assertOwnedR2Key(
|
|
87
|
+
key: string,
|
|
88
|
+
organizationId: string
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
if (!key.startsWith(`uploads/organizations/${organizationId}/`)) {
|
|
91
|
+
throw new AppError(
|
|
92
|
+
AppErrorCode.FORBIDDEN,
|
|
93
|
+
'You do not have permission to use this file.'
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const exists = await db.query.fileUploadsTable.findFirst({
|
|
98
|
+
where: and(
|
|
99
|
+
eq(fileUploadsTable.key, key),
|
|
100
|
+
eq(fileUploadsTable.organizationId, organizationId)
|
|
101
|
+
),
|
|
102
|
+
columns: { id: true }
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
if (!exists) {
|
|
106
|
+
throw new AppError(
|
|
107
|
+
AppErrorCode.FORBIDDEN,
|
|
108
|
+
'You do not have permission to use this file.'
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Deletes a file from R2 and removes its tracking row.
|
|
115
|
+
*
|
|
116
|
+
* Trusted-caller primitive with no ownership check. Validate a key that came
|
|
117
|
+
* from user input with {@link assertOwnedR2Key} first.
|
|
118
|
+
*/
|
|
67
119
|
export async function deleteFile(key: string): Promise<void> {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
new DeleteObjectCommand({
|
|
71
|
-
Bucket: env.R2_BUCKET_NAME,
|
|
72
|
-
Key: key
|
|
73
|
-
})
|
|
120
|
+
await getR2Client().send(
|
|
121
|
+
new DeleteObjectCommand({ Bucket: env.R2_BUCKET_NAME, Key: key })
|
|
74
122
|
)
|
|
75
123
|
|
|
76
124
|
await db.delete(fileUploadsTable).where(eq(fileUploadsTable.key, key))
|
|
77
125
|
}
|
|
78
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Presigns a PUT for a direct browser-to-R2 upload and records it as
|
|
129
|
+
* temporary. Content-Type is signed, so the browser cannot upload `text/html`
|
|
130
|
+
* under an image key.
|
|
131
|
+
*
|
|
132
|
+
* The browser PUTs the file to `uploadUrl`, then the action that saves the
|
|
133
|
+
* resource calls {@link confirmUpload} with the returned `key`.
|
|
134
|
+
*/
|
|
79
135
|
export async function generateUploadUrl(params: {
|
|
80
136
|
filename: string
|
|
81
|
-
mimeType:
|
|
137
|
+
mimeType: R2UploadMimeType
|
|
82
138
|
size: number
|
|
83
139
|
uploadedBy: string
|
|
84
|
-
organizationId
|
|
85
|
-
}): Promise<{
|
|
86
|
-
|
|
87
|
-
key: string
|
|
88
|
-
}> {
|
|
89
|
-
const ext = params.filename.split('.').pop()?.toLowerCase() ?? 'bin'
|
|
90
|
-
const folder = params.organizationId
|
|
91
|
-
? `uploads/organizations/${params.organizationId}`
|
|
92
|
-
: `uploads/misc`
|
|
93
|
-
|
|
140
|
+
organizationId: string
|
|
141
|
+
}): Promise<{ uploadUrl: string; key: string }> {
|
|
142
|
+
const ext = R2_UPLOAD_EXTENSION_BY_MIME_TYPE[params.mimeType]
|
|
94
143
|
const fileId = createId()
|
|
95
|
-
const key =
|
|
144
|
+
const key = `uploads/organizations/${params.organizationId}/${fileId}.${ext}`
|
|
96
145
|
|
|
97
|
-
const client = getR2Client()
|
|
98
146
|
const uploadUrl = await getSignedUrl(
|
|
99
|
-
|
|
147
|
+
getR2Client(),
|
|
100
148
|
new PutObjectCommand({
|
|
101
149
|
Bucket: env.R2_BUCKET_NAME!,
|
|
102
150
|
Key: key,
|
|
103
151
|
ContentType: params.mimeType,
|
|
104
152
|
ContentLength: params.size
|
|
105
153
|
}),
|
|
106
|
-
{
|
|
154
|
+
{
|
|
155
|
+
expiresIn: PRESIGNED_URL_TTL_SECONDS,
|
|
156
|
+
signableHeaders: new Set(['content-type'])
|
|
157
|
+
}
|
|
107
158
|
)
|
|
108
159
|
|
|
109
160
|
await db.insert(fileUploadsTable).values({
|
|
@@ -120,35 +171,114 @@ export async function generateUploadUrl(params: {
|
|
|
120
171
|
return { uploadUrl, key }
|
|
121
172
|
}
|
|
122
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Marks an upload permanent and deletes the file it replaces.
|
|
176
|
+
*
|
|
177
|
+
* Call it in the action that saves the resource. `newKey` must belong to
|
|
178
|
+
* `organizationId`, and this throws otherwise. Deleting `oldKey` is
|
|
179
|
+
* best-effort and never blocks the save.
|
|
180
|
+
*/
|
|
123
181
|
export async function confirmUpload(
|
|
124
182
|
newKey: string,
|
|
183
|
+
organizationId: string,
|
|
125
184
|
oldKey?: string | null
|
|
126
185
|
): Promise<void> {
|
|
186
|
+
await assertOwnedR2Key(newKey, organizationId)
|
|
187
|
+
|
|
127
188
|
await db
|
|
128
189
|
.update(fileUploadsTable)
|
|
129
190
|
.set({ isTemp: false })
|
|
130
191
|
.where(eq(fileUploadsTable.key, newKey))
|
|
131
192
|
|
|
132
|
-
if (oldKey &&
|
|
133
|
-
await
|
|
134
|
-
|
|
135
|
-
|
|
193
|
+
if (oldKey && oldKey !== newKey)
|
|
194
|
+
await deleteOwnedFileQuietly(oldKey, organizationId)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Deletes a replaced or cleared file when `organizationId` owns it. Failures
|
|
199
|
+
* are swallowed: a stale object costs storage, not correctness.
|
|
200
|
+
*/
|
|
201
|
+
export async function deleteOwnedFileQuietly(
|
|
202
|
+
key: string,
|
|
203
|
+
organizationId: string
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
if (!isR2Key(key) || !isR2Configured()) return
|
|
206
|
+
|
|
207
|
+
const owned = await assertOwnedR2Key(key, organizationId)
|
|
208
|
+
.then(() => true)
|
|
209
|
+
.catch(() => false)
|
|
210
|
+
|
|
211
|
+
if (owned) await deleteFile(key).catch(() => {})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Deletes every upload an organization owns, from R2 and the database.
|
|
216
|
+
* `file_uploads.organization_id` restricts deletes, so the organization row
|
|
217
|
+
* can only go once this has run.
|
|
218
|
+
*/
|
|
219
|
+
export async function deleteOrganizationUploads(
|
|
220
|
+
organizationId: string
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
const uploads = await db
|
|
223
|
+
.select({ key: fileUploadsTable.key })
|
|
224
|
+
.from(fileUploadsTable)
|
|
225
|
+
.where(eq(fileUploadsTable.organizationId, organizationId))
|
|
226
|
+
|
|
227
|
+
if (isR2Configured()) {
|
|
228
|
+
for (const upload of uploads) {
|
|
229
|
+
await deleteFile(upload.key)
|
|
230
|
+
}
|
|
136
231
|
}
|
|
232
|
+
|
|
233
|
+
await db
|
|
234
|
+
.delete(fileUploadsTable)
|
|
235
|
+
.where(eq(fileUploadsTable.organizationId, organizationId))
|
|
137
236
|
}
|
|
138
237
|
|
|
238
|
+
/** True when `value` is an R2 object key (`uploads/organizations/<id>/<file>.<ext>`). */
|
|
139
239
|
export function isR2Key(value: string | null | undefined): value is string {
|
|
140
|
-
return !!value &&
|
|
240
|
+
return !!value && isR2ObjectKey(value)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Rejects image values that must not be written to a resource. This blocks
|
|
245
|
+
* `data:` URLs when R2 is configured. Ownership stays in {@link assertOwnedR2Key}.
|
|
246
|
+
*/
|
|
247
|
+
export function assertPersistableImageValue(
|
|
248
|
+
value: string | null | undefined
|
|
249
|
+
): void {
|
|
250
|
+
if (!value) return
|
|
251
|
+
if (canPersistImageValue(value, isR2Configured())) return
|
|
252
|
+
throw new AppError(
|
|
253
|
+
AppErrorCode.BAD_REQUEST,
|
|
254
|
+
'Upload an image file. Embedded images are not stored.'
|
|
255
|
+
)
|
|
141
256
|
}
|
|
142
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Resolves a stored image value to a renderable URL.
|
|
260
|
+
*
|
|
261
|
+
* - R2 object key: prefixed with `R2_PUBLIC_URL`
|
|
262
|
+
* - `data:image/*;base64,...` (dev without R2): returned as-is
|
|
263
|
+
* - anything else, including `https://` and `javascript:`: `null`
|
|
264
|
+
*/
|
|
143
265
|
export function resolveImageUrl(
|
|
144
266
|
value: string | null | undefined
|
|
145
267
|
): string | null {
|
|
146
268
|
if (!value) return null
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
269
|
+
if (isR2ObjectKey(value)) {
|
|
270
|
+
if (!isR2Configured()) return null
|
|
271
|
+
return `${env.R2_PUBLIC_URL}/${value}`
|
|
272
|
+
}
|
|
273
|
+
if (isDataImageUrl(value)) return value
|
|
274
|
+
return null
|
|
150
275
|
}
|
|
151
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Deletes temporary uploads older than {@link TEMP_CLEANUP_THRESHOLD_MS}.
|
|
279
|
+
* Per-file errors are counted, not thrown, so one failure cannot abort the
|
|
280
|
+
* batch. `bun run storage:cleanup` runs it; schedule that command.
|
|
281
|
+
*/
|
|
152
282
|
export async function cleanupTempUploads(): Promise<{
|
|
153
283
|
deleted: number
|
|
154
284
|
errors: number
|
|
@@ -156,7 +286,7 @@ export async function cleanupTempUploads(): Promise<{
|
|
|
156
286
|
const threshold = new Date(Date.now() - TEMP_CLEANUP_THRESHOLD_MS)
|
|
157
287
|
|
|
158
288
|
const stale = await db
|
|
159
|
-
.select({
|
|
289
|
+
.select({ key: fileUploadsTable.key })
|
|
160
290
|
.from(fileUploadsTable)
|
|
161
291
|
.where(
|
|
162
292
|
and(
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
1
3
|
import Stripe from 'stripe'
|
|
2
4
|
import { env } from '~/env'
|
|
3
5
|
|
|
6
|
+
/** Pinned to the API version this `stripe` SDK release was built for. Bump both together. */
|
|
4
7
|
export const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, {
|
|
5
|
-
apiVersion: '2026-
|
|
8
|
+
apiVersion: '2026-08-26.dahlia'
|
|
6
9
|
})
|
|
@@ -2,14 +2,100 @@
|
|
|
2
2
|
|
|
3
3
|
import { toast } from 'sonner'
|
|
4
4
|
|
|
5
|
+
const DEFAULT_ERROR = 'The request did not finish. Try again in a few minutes.'
|
|
6
|
+
|
|
7
|
+
type ActionResultLike = {
|
|
8
|
+
serverError?: string
|
|
9
|
+
validationErrors?: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Displays a success toast notification to the user.
|
|
14
|
+
*/
|
|
5
15
|
export const toastSuccessMessage = (message: string) =>
|
|
6
16
|
toast.success(message, {
|
|
7
17
|
richColors: true,
|
|
8
18
|
position: 'top-center'
|
|
9
19
|
})
|
|
10
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Displays an informational toast (neutral, non-error feedback).
|
|
23
|
+
*/
|
|
24
|
+
export const toastInfoMessage = (message: string) =>
|
|
25
|
+
toast.info(message, {
|
|
26
|
+
richColors: true,
|
|
27
|
+
position: 'top-center'
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Displays an error toast. Uses a default message when none is provided.
|
|
32
|
+
*/
|
|
11
33
|
export const toastErrorMessage = (message?: string) =>
|
|
12
|
-
toast.error(message ||
|
|
34
|
+
toast.error(message || DEFAULT_ERROR, {
|
|
13
35
|
richColors: true,
|
|
14
36
|
position: 'top-center'
|
|
15
37
|
})
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Toast an action failure from `await someAction(...)`.
|
|
41
|
+
* Returns true when a toast was shown so callers can bail.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const result = await upsertTableAction(values)
|
|
45
|
+
* if (toastIfActionFailed(result)) return
|
|
46
|
+
*/
|
|
47
|
+
export function toastIfActionFailed(
|
|
48
|
+
result: ActionResultLike | undefined | null
|
|
49
|
+
): boolean {
|
|
50
|
+
if (!result) return false
|
|
51
|
+
if (result.serverError) {
|
|
52
|
+
toastErrorMessage(result.serverError)
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
if (result.validationErrors) {
|
|
56
|
+
toastErrorMessage('Check the highlighted fields and try again.')
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Toast a failure from hooks (`useSafeActionMutation`, `useAction`) or a thrown Error.
|
|
64
|
+
*
|
|
65
|
+
* Accepts:
|
|
66
|
+
* - `SafeActionError` / `Error` (`message`)
|
|
67
|
+
* - `useAction` error bag (`{ serverError, validationErrors }`)
|
|
68
|
+
* - plain string
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* onError: error => toastActionError(error, 'Failed to save')
|
|
72
|
+
* onError: ({ error }) => toastActionError(error, 'Failed to save')
|
|
73
|
+
*/
|
|
74
|
+
export function toastActionError(
|
|
75
|
+
error: unknown,
|
|
76
|
+
fallback = DEFAULT_ERROR
|
|
77
|
+
): void {
|
|
78
|
+
if (typeof error === 'string' && error.trim()) {
|
|
79
|
+
toastErrorMessage(error)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (error && typeof error === 'object') {
|
|
84
|
+
const bag = error as ActionResultLike & { message?: string }
|
|
85
|
+
|
|
86
|
+
if (typeof bag.serverError === 'string' && bag.serverError) {
|
|
87
|
+
toastErrorMessage(bag.serverError)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (bag.validationErrors) {
|
|
91
|
+
toastErrorMessage('Check the highlighted fields and try again.')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (typeof bag.message === 'string' && bag.message.trim()) {
|
|
95
|
+
toastErrorMessage(bag.message)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
toastErrorMessage(fallback)
|
|
101
|
+
}
|