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,14 +1,27 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
1
3
|
import { eq } from 'drizzle-orm'
|
|
2
4
|
import { db } from '~/db'
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
+
import {
|
|
6
|
+
organizationSubscriptionStatusEnum,
|
|
7
|
+
organizationSubscriptionsTable,
|
|
8
|
+
organizationsTable
|
|
9
|
+
} from '~/db/schema'
|
|
10
|
+
import { AppError, AppErrorCode } from './errors'
|
|
11
|
+
import { PLANS, hasLiveSubscription, type PlanId } from './plans'
|
|
5
12
|
import { stripeClient } from './stripe'
|
|
6
13
|
|
|
7
14
|
type SubscriptionRow = typeof organizationSubscriptionsTable.$inferSelect
|
|
15
|
+
type SubscriptionStatus = SubscriptionRow['status']
|
|
16
|
+
|
|
17
|
+
const KNOWN_STATUSES = new Set<string>(
|
|
18
|
+
organizationSubscriptionStatusEnum.enumValues
|
|
19
|
+
)
|
|
8
20
|
|
|
21
|
+
/** Maps a Stripe price back to the plan and billing interval it belongs to. */
|
|
9
22
|
export const getPlanAndIntervalByPriceId = (
|
|
10
23
|
priceId: string
|
|
11
|
-
): { planId:
|
|
24
|
+
): { planId: PlanId; interval: 'month' | 'year' } | null => {
|
|
12
25
|
for (const plan of PLANS) {
|
|
13
26
|
if (!plan.stripePrice) continue
|
|
14
27
|
if (plan.stripePrice.monthly.id === priceId)
|
|
@@ -19,56 +32,71 @@ export const getPlanAndIntervalByPriceId = (
|
|
|
19
32
|
return null
|
|
20
33
|
}
|
|
21
34
|
|
|
35
|
+
/** One trial per organization, and only from the free plan. */
|
|
22
36
|
export const canStartTrial = (
|
|
23
37
|
sub: Pick<SubscriptionRow, 'status' | 'hasUsedTrial'>
|
|
24
38
|
): boolean => sub.status === 'free' && !sub.hasUsedTrial
|
|
25
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Returns the organization's Stripe customer, creating one on first checkout.
|
|
42
|
+
* A billing email is required first so receipts have somewhere to go.
|
|
43
|
+
*/
|
|
26
44
|
export const ensureStripeCustomer = async (
|
|
27
|
-
|
|
45
|
+
sub: SubscriptionRow
|
|
28
46
|
): Promise<string> => {
|
|
29
|
-
const sub = await db.query.organizationSubscriptionsTable.findFirst({
|
|
30
|
-
where: eq(organizationSubscriptionsTable.organizationId, organizationId)
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
if (!sub) {
|
|
34
|
-
throw new Error('Organization does not have a subscription')
|
|
35
|
-
}
|
|
36
|
-
|
|
37
47
|
if (sub.stripeCustomerId) return sub.stripeCustomerId
|
|
38
48
|
|
|
39
49
|
if (!sub.billingEmail) {
|
|
40
|
-
throw new
|
|
50
|
+
throw new AppError(
|
|
51
|
+
AppErrorCode.BAD_REQUEST,
|
|
52
|
+
'Add a billing email before you choose a paid plan.'
|
|
53
|
+
)
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
const org = await db.query.organizationsTable.findFirst({
|
|
44
|
-
where: eq(organizationsTable.id, organizationId),
|
|
57
|
+
where: eq(organizationsTable.id, sub.organizationId),
|
|
45
58
|
columns: { name: true }
|
|
46
59
|
})
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
// The idempotency key makes a double-clicked checkout reuse one customer
|
|
62
|
+
// instead of orphaning the first one's webhooks.
|
|
63
|
+
const customer = await stripeClient.customers.create(
|
|
64
|
+
{
|
|
65
|
+
email: sub.billingEmail,
|
|
66
|
+
name: org?.name,
|
|
67
|
+
metadata: { organizationId: sub.organizationId }
|
|
68
|
+
},
|
|
69
|
+
{ idempotencyKey: `organization-${sub.organizationId}-customer` }
|
|
70
|
+
)
|
|
53
71
|
|
|
54
72
|
await db
|
|
55
73
|
.update(organizationSubscriptionsTable)
|
|
56
74
|
.set({ stripeCustomerId: customer.id })
|
|
57
|
-
.where(eq(organizationSubscriptionsTable.
|
|
75
|
+
.where(eq(organizationSubscriptionsTable.id, sub.id))
|
|
58
76
|
|
|
59
77
|
return customer.id
|
|
60
78
|
}
|
|
61
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Copies the customer's latest Stripe subscription onto the organization.
|
|
82
|
+
*
|
|
83
|
+
* Webhooks call this for every relevant event, so it reads the current state
|
|
84
|
+
* from Stripe instead of trusting the event payload, which may arrive out of
|
|
85
|
+
* order.
|
|
86
|
+
*/
|
|
62
87
|
export const syncSubscriptionFromStripe = async (
|
|
63
88
|
customerId: string
|
|
64
89
|
): Promise<void> => {
|
|
65
90
|
const subs = await stripeClient.subscriptions.list({
|
|
66
91
|
customer: customerId,
|
|
67
92
|
status: 'all',
|
|
68
|
-
limit:
|
|
93
|
+
limit: 10
|
|
69
94
|
})
|
|
70
95
|
|
|
71
|
-
|
|
96
|
+
// Newest first. Prefer a subscription that still bills over a newer one
|
|
97
|
+
// that ended, so the organization never drops to free while paying.
|
|
98
|
+
const sub =
|
|
99
|
+
subs.data.find(item => hasLiveSubscription(item.status)) ?? subs.data[0]
|
|
72
100
|
|
|
73
101
|
if (
|
|
74
102
|
!sub ||
|
|
@@ -90,12 +118,19 @@ export const syncSubscriptionFromStripe = async (
|
|
|
90
118
|
return
|
|
91
119
|
}
|
|
92
120
|
|
|
121
|
+
if (!KNOWN_STATUSES.has(sub.status)) {
|
|
122
|
+
throw new AppError(
|
|
123
|
+
AppErrorCode.INTERNAL,
|
|
124
|
+
`Stripe subscription ${sub.id} has unknown status ${sub.status}. Add it to the status enum to sync it.`
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
93
128
|
const item = sub.items.data[0]
|
|
94
129
|
const resolved = item ? getPlanAndIntervalByPriceId(item.price.id) : null
|
|
95
130
|
|
|
96
131
|
if (!resolved) {
|
|
97
132
|
console.error(
|
|
98
|
-
`Stripe price ${item?.price.id} on subscription ${sub.id} does not map to a
|
|
133
|
+
`Stripe price ${item?.price.id} on subscription ${sub.id} does not map to a plan; the organization stays on the free plan.`
|
|
99
134
|
)
|
|
100
135
|
}
|
|
101
136
|
|
|
@@ -105,7 +140,7 @@ export const syncSubscriptionFromStripe = async (
|
|
|
105
140
|
.update(organizationSubscriptionsTable)
|
|
106
141
|
.set({
|
|
107
142
|
plan: resolved?.planId ?? 'free',
|
|
108
|
-
status: sub.status,
|
|
143
|
+
status: sub.status as SubscriptionStatus,
|
|
109
144
|
interval: resolved?.interval ?? null,
|
|
110
145
|
stripeSubscriptionId: sub.id,
|
|
111
146
|
trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Origin used only to resolve a candidate callback and check where it lands. */
|
|
2
|
+
const RESOLUTION_ORIGIN = 'https://callback.invalid'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Narrow a caller-supplied `?callbackUrl=` down to a same-origin path.
|
|
6
|
+
*
|
|
7
|
+
* Returns the normalised path, or null when the value could leave the site.
|
|
8
|
+
* A leading `/` is not enough on its own: `//evil.com`, `/\evil.com`, and
|
|
9
|
+
* `/<tab>/evil.com` all resolve cross-origin in a browser, the last because
|
|
10
|
+
* URL parsing strips tabs and newlines first. The shape checks below reject
|
|
11
|
+
* the known forms, and resolving against a throwaway origin catches the rest.
|
|
12
|
+
*/
|
|
13
|
+
export function sanitizeCallbackUrl(
|
|
14
|
+
value: string | string[] | undefined
|
|
15
|
+
): string | null {
|
|
16
|
+
if (typeof value !== 'string') return null
|
|
17
|
+
if (!/^\/(?![/\\])/.test(value)) return null
|
|
18
|
+
if (/[\t\n\r]/.test(value)) return null
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const resolved = new URL(value, RESOLUTION_ORIGIN)
|
|
22
|
+
if (resolved.origin !== RESOLUTION_ORIGIN) return null
|
|
23
|
+
|
|
24
|
+
return `${resolved.pathname}${resolved.search}${resolved.hash}`
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,22 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Application-wide constants for configuration and validation.
|
|
3
|
+
*
|
|
4
|
+
* Read limits, labels, and repeated values from here in the UI, validation,
|
|
5
|
+
* tests, and docs, so one change updates every place.
|
|
6
|
+
*
|
|
7
|
+
* @module lib/constants
|
|
8
|
+
*/
|
|
9
|
+
|
|
1
10
|
// ============================================
|
|
2
|
-
// BILLING
|
|
11
|
+
// BILLING
|
|
3
12
|
// ============================================
|
|
4
13
|
|
|
5
|
-
|
|
14
|
+
/** Length of the free trial on paid plans that offer one */
|
|
6
15
|
export const TRIAL_DAYS = 7
|
|
7
16
|
|
|
8
17
|
// ============================================
|
|
9
|
-
//
|
|
18
|
+
// ORGANIZATIONS
|
|
19
|
+
// ============================================
|
|
20
|
+
|
|
21
|
+
/** Organizations one user can create */
|
|
22
|
+
export const MAX_ORGANIZATIONS_PER_USER = 10
|
|
23
|
+
|
|
24
|
+
/** Days an organization invitation stays valid */
|
|
25
|
+
export const INVITATION_EXPIRES_IN_DAYS = 7
|
|
26
|
+
|
|
27
|
+
// ============================================
|
|
28
|
+
// R2 STORAGE
|
|
10
29
|
// ============================================
|
|
11
30
|
|
|
12
|
-
|
|
13
|
-
export const PRESIGNED_URL_TTL_SECONDS = 900
|
|
31
|
+
/** Seconds the client has to complete a presigned upload */
|
|
32
|
+
export const PRESIGNED_URL_TTL_SECONDS = 900
|
|
14
33
|
|
|
15
|
-
|
|
16
|
-
export const TEMP_CLEANUP_THRESHOLD_MS = 2 * 60 * 60 * 1000
|
|
34
|
+
/** Age after which an unconfirmed upload is deleted */
|
|
35
|
+
export const TEMP_CLEANUP_THRESHOLD_MS = 2 * 60 * 60 * 1000
|
|
17
36
|
|
|
18
|
-
|
|
37
|
+
/** Maximum upload size in MB */
|
|
19
38
|
export const MAX_FILE_SIZE_MB = 5
|
|
20
39
|
|
|
21
|
-
|
|
40
|
+
/** Maximum upload size in bytes */
|
|
22
41
|
export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
/** MIME types the upload flow accepts, mapped to the extension stored on the object key */
|
|
44
|
+
export const R2_UPLOAD_EXTENSION_BY_MIME_TYPE = {
|
|
45
|
+
'image/jpeg': 'jpg',
|
|
46
|
+
'image/png': 'png',
|
|
47
|
+
'image/webp': 'webp',
|
|
48
|
+
'image/gif': 'gif'
|
|
49
|
+
} as const
|
|
50
|
+
|
|
51
|
+
export type R2UploadMimeType = keyof typeof R2_UPLOAD_EXTENSION_BY_MIME_TYPE
|
|
52
|
+
|
|
53
|
+
// ============================================
|
|
54
|
+
// REQUEST ARRAY BOUNDS
|
|
55
|
+
// ============================================
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* Ceilings for arrays that reach SQL as an `IN (…)` list. Postgres caps a
|
|
59
|
+
* statement at 65535 bind parameters, and an unbounded array also builds in
|
|
60
|
+
* server memory first.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/** Values accepted in one list-filter array, such as status facets */
|
|
64
|
+
export const MAX_FILTER_VALUES = 100
|
|
65
|
+
|
|
66
|
+
// ============================================
|
|
67
|
+
// TEXT AND INTEGER INPUT BOUNDS
|
|
68
|
+
// ============================================
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Image value ceiling.
|
|
72
|
+
* Covers R2 object keys and base64 data URLs for the max upload size.
|
|
73
|
+
*/
|
|
74
|
+
export const MAX_IMAGE_URL_LENGTH =
|
|
75
|
+
Math.ceil(MAX_FILE_SIZE_BYTES * (4 / 3)) + 64
|
|
76
|
+
|
|
77
|
+
/** URL slug for organizations */
|
|
78
|
+
export const MAX_SLUG_LENGTH = 64
|
|
79
|
+
|
|
80
|
+
/** Feedback comments */
|
|
81
|
+
export const MAX_COMMENT_LENGTH = 3_000
|
|
82
|
+
|
|
83
|
+
/** Support ticket bodies and contact form messages */
|
|
84
|
+
export const MAX_MESSAGE_LENGTH = 5_000
|
|
85
|
+
|
|
86
|
+
/** Shortest contact form message worth answering */
|
|
87
|
+
export const MIN_CONTACT_MESSAGE_LENGTH = 10
|
|
88
|
+
|
|
89
|
+
/** Star ratings on feedback */
|
|
90
|
+
export const MIN_RATING = 1
|
|
91
|
+
export const MAX_RATING = 5
|
|
@@ -1,2 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a promise that resolves after a specified delay
|
|
3
|
+
* Useful for adding delays in async functions or testing
|
|
4
|
+
*
|
|
5
|
+
* @param ms - Milliseconds to delay
|
|
6
|
+
* @returns Promise that resolves after the delay
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* await delay(1000) // Wait 1 second
|
|
10
|
+
*/
|
|
1
11
|
export const delay = (ms: number) =>
|
|
2
12
|
new Promise(resolve => setTimeout(resolve, ms))
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
1
3
|
import { render, toPlainText } from '@react-email/render'
|
|
2
4
|
import InvitationEmail, { InvitationEmailProps } from '~/emails/invitation'
|
|
3
5
|
import MagicLinkEmail, { MagicLinkEmailProps } from '~/emails/magic-link'
|
|
@@ -25,6 +27,9 @@ const componentMap: Record<
|
|
|
25
27
|
invitation: props => InvitationEmail(props as InvitationEmailProps)
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Generic email template renderer with type-safe overloads
|
|
32
|
+
*/
|
|
28
33
|
async function renderEmailTemplate<T extends EmailTemplateType>(
|
|
29
34
|
templateType: T,
|
|
30
35
|
props: EmailTemplateMap[T]
|
|
@@ -43,6 +48,14 @@ async function renderEmailTemplate(
|
|
|
43
48
|
return { html, text }
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Sends welcome email to newly registered users
|
|
53
|
+
*
|
|
54
|
+
* @param to - Recipient email address
|
|
55
|
+
* @param props - Welcome email template props
|
|
56
|
+
* @returns Promise resolving to SentMessageInfo from nodemailer
|
|
57
|
+
* @throws Error if sending the email fails
|
|
58
|
+
*/
|
|
46
59
|
export async function sendWelcomeEmail(to: string, props: WelcomeEmailProps) {
|
|
47
60
|
'use workflow'
|
|
48
61
|
|
|
@@ -56,6 +69,14 @@ export async function sendWelcomeEmail(to: string, props: WelcomeEmailProps) {
|
|
|
56
69
|
})
|
|
57
70
|
}
|
|
58
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Sends magic link email for passwordless authentication
|
|
74
|
+
*
|
|
75
|
+
* @param to - Recipient email address
|
|
76
|
+
* @param props - Magic link email template props
|
|
77
|
+
* @returns Promise resolving to SentMessageInfo from nodemailer
|
|
78
|
+
* @throws Error if sending the email fails
|
|
79
|
+
*/
|
|
59
80
|
export async function sendMagicLinkEmail(
|
|
60
81
|
to: string,
|
|
61
82
|
props: MagicLinkEmailProps
|
|
@@ -72,6 +93,14 @@ export async function sendMagicLinkEmail(
|
|
|
72
93
|
})
|
|
73
94
|
}
|
|
74
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Sends an organization invitation email
|
|
98
|
+
*
|
|
99
|
+
* @param to - Recipient email address
|
|
100
|
+
* @param props - Invitation email template props
|
|
101
|
+
* @returns Promise resolving to SentMessageInfo from nodemailer
|
|
102
|
+
* @throws Error if sending the email fails
|
|
103
|
+
*/
|
|
75
104
|
export async function sendInvitationEmail(
|
|
76
105
|
to: string,
|
|
77
106
|
props: InvitationEmailProps
|
|
@@ -82,7 +111,7 @@ export async function sendInvitationEmail(
|
|
|
82
111
|
|
|
83
112
|
return await sendEmail({
|
|
84
113
|
to,
|
|
85
|
-
subject:
|
|
114
|
+
subject: `${props.inviterName} invited you to join ${props.organizationName}`,
|
|
86
115
|
html,
|
|
87
116
|
text
|
|
88
117
|
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expected, user-safe action failures.
|
|
3
|
+
*
|
|
4
|
+
* Throw these for domain and authorization failures. The safe-action
|
|
5
|
+
* `handleServerError` returns {@link AppError.message} to the client and
|
|
6
|
+
* records the code on the active span. Unexpected errors stay masked.
|
|
7
|
+
*
|
|
8
|
+
* Do not put stack traces, SQL, or internal IDs in `message`.
|
|
9
|
+
*/
|
|
10
|
+
export const AppErrorCode = {
|
|
11
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
12
|
+
FORBIDDEN: 'FORBIDDEN',
|
|
13
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
14
|
+
CONFLICT: 'CONFLICT',
|
|
15
|
+
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
|
|
16
|
+
FEATURE_NOT_INCLUDED: 'FEATURE_NOT_INCLUDED',
|
|
17
|
+
BAD_REQUEST: 'BAD_REQUEST',
|
|
18
|
+
INTERNAL: 'INTERNAL'
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode]
|
|
22
|
+
|
|
23
|
+
export class AppError extends Error {
|
|
24
|
+
readonly code: AppErrorCode
|
|
25
|
+
readonly meta?: Record<string, unknown>
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
code: AppErrorCode,
|
|
29
|
+
message: string,
|
|
30
|
+
meta?: Record<string, unknown>
|
|
31
|
+
) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'AppError'
|
|
34
|
+
this.code = code
|
|
35
|
+
this.meta = meta
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isAppError(error: unknown): error is AppError {
|
|
40
|
+
return error instanceof AppError
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The Postgres SQLSTATE of a failed query. Drizzle wraps driver errors and
|
|
45
|
+
* keeps the original on `cause`, so read both.
|
|
46
|
+
*/
|
|
47
|
+
function postgresCode(error: unknown, depth = 0): unknown {
|
|
48
|
+
// Drizzle adds one layer; the cap stops an error whose cause points back at
|
|
49
|
+
// itself from looping forever.
|
|
50
|
+
if (depth > 5 || typeof error !== 'object' || error === null) return undefined
|
|
51
|
+
if ('code' in error && typeof error.code === 'string') return error.code
|
|
52
|
+
return 'cause' in error ? postgresCode(error.cause, depth + 1) : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isUniqueViolation(error: unknown): boolean {
|
|
56
|
+
return postgresCode(error) === '23505'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* True when a delete or update is refused because another row still points at
|
|
61
|
+
* the row: a foreign key violation, or a `restrict` violation for keys declared
|
|
62
|
+
* `onDelete: 'restrict'`.
|
|
63
|
+
*/
|
|
64
|
+
export function isForeignKeyViolation(error: unknown): boolean {
|
|
65
|
+
const code = postgresCode(error)
|
|
66
|
+
return code === '23503' || code === '23001'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function rethrowUniqueViolation(error: unknown, message: string): never {
|
|
70
|
+
if (isUniqueViolation(error)) {
|
|
71
|
+
throw new AppError(AppErrorCode.CONFLICT, message)
|
|
72
|
+
}
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
@@ -1,27 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
currencyCode: string,
|
|
3
|
-
num: number | string,
|
|
4
|
-
precision = 2
|
|
5
|
-
) => {
|
|
6
|
-
const numCopy = Number(num)
|
|
1
|
+
const formatters = new Map<string, Intl.NumberFormat>()
|
|
7
2
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Formats an amount in its currency, such as `$20.00` or `€20.00`.
|
|
5
|
+
* `Intl` picks the symbol and the minor digits from the ISO currency code.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* formatCurrency('USD', 20) // '$20.00'
|
|
9
|
+
*/
|
|
10
|
+
export function formatCurrency(
|
|
11
|
+
currencyCode: string,
|
|
12
|
+
value: number | string
|
|
13
|
+
): string {
|
|
14
|
+
let formatter = formatters.get(currencyCode)
|
|
15
|
+
if (!formatter) {
|
|
16
|
+
formatter = new Intl.NumberFormat('en-US', {
|
|
17
|
+
style: 'currency',
|
|
18
|
+
currency: currencyCode,
|
|
19
|
+
currencyDisplay: 'narrowSymbol',
|
|
20
|
+
signDisplay: 'negative'
|
|
21
|
+
})
|
|
22
|
+
formatters.set(currencyCode, formatter)
|
|
26
23
|
}
|
|
24
|
+
return formatter.format(Number(value))
|
|
27
25
|
}
|
|
@@ -1,12 +1,75 @@
|
|
|
1
|
+
export type DateFormatOptions = {
|
|
2
|
+
/** Abbreviated month ("Jan") instead of the full name. */
|
|
3
|
+
short?: boolean
|
|
4
|
+
/** Append the time of day. */
|
|
5
|
+
time?: boolean
|
|
6
|
+
/** Drop the year. */
|
|
7
|
+
noYear?: boolean
|
|
8
|
+
/** Render in this IANA zone instead of the viewer's. */
|
|
9
|
+
timeZone?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function asValidDate(
|
|
13
|
+
date: Date | string | number | null | undefined
|
|
14
|
+
): Date | null {
|
|
15
|
+
if (date == null) return null
|
|
16
|
+
const instant = new Date(date)
|
|
17
|
+
return Number.isFinite(instant.getTime()) ? instant : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Date and time are formatted separately and joined with a fixed " at ",
|
|
22
|
+
* because the connector ICU inserts between them changed across versions
|
|
23
|
+
* (", " in ICU 72 and later). Bun, Node, and each browser ship different ICU
|
|
24
|
+
* data, so one `toLocaleString` call renders differently per viewer and
|
|
25
|
+
* breaks hydration.
|
|
26
|
+
*/
|
|
27
|
+
function joinDateAndTime(
|
|
28
|
+
instant: Date,
|
|
29
|
+
dateOptions: Intl.DateTimeFormatOptions,
|
|
30
|
+
timeOptions: Intl.DateTimeFormatOptions,
|
|
31
|
+
timeZone: string | undefined
|
|
32
|
+
): string {
|
|
33
|
+
const zone = timeZone ? { timeZone } : {}
|
|
34
|
+
const datePart = instant.toLocaleDateString('en-US', {
|
|
35
|
+
...dateOptions,
|
|
36
|
+
...zone
|
|
37
|
+
})
|
|
38
|
+
const timePart = instant.toLocaleTimeString('en-US', {
|
|
39
|
+
...timeOptions,
|
|
40
|
+
...zone
|
|
41
|
+
})
|
|
42
|
+
return `${datePart} at ${timePart}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Formats a date as an `en-US` string, such as "March 3, 2026".
|
|
47
|
+
* Returns `-` for a missing or invalid date.
|
|
48
|
+
*/
|
|
1
49
|
export const formatDate = (
|
|
2
50
|
date: Date | string | number | null | undefined,
|
|
3
|
-
options?:
|
|
51
|
+
options?: DateFormatOptions
|
|
4
52
|
): string => {
|
|
5
|
-
|
|
6
|
-
|
|
53
|
+
const instant = asValidDate(date)
|
|
54
|
+
if (!instant) return '-'
|
|
55
|
+
|
|
56
|
+
const dateOptions: Intl.DateTimeFormatOptions = {
|
|
7
57
|
...(options?.noYear ? {} : { year: 'numeric' }),
|
|
8
58
|
month: options?.short ? 'short' : 'long',
|
|
9
|
-
day: 'numeric'
|
|
10
|
-
|
|
11
|
-
|
|
59
|
+
day: 'numeric'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!options?.time) {
|
|
63
|
+
return instant.toLocaleDateString('en-US', {
|
|
64
|
+
...dateOptions,
|
|
65
|
+
...(options?.timeZone ? { timeZone: options.timeZone } : {})
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return joinDateAndTime(
|
|
70
|
+
instant,
|
|
71
|
+
dateOptions,
|
|
72
|
+
{ hour: '2-digit', minute: '2-digit' },
|
|
73
|
+
options.timeZone
|
|
74
|
+
)
|
|
12
75
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image field formats stored on organizations.
|
|
3
|
+
*
|
|
4
|
+
* Client-safe: no env, DB, or server-only imports. Zod schemas and
|
|
5
|
+
* `resolveImageUrl` share these predicates so only R2 keys and data image
|
|
6
|
+
* URLs reach the renderer.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
|
|
10
|
+
type R2UploadMimeType
|
|
11
|
+
} from '~/lib/constants'
|
|
12
|
+
|
|
13
|
+
const IMAGE_EXTENSIONS = Object.values(R2_UPLOAD_EXTENSION_BY_MIME_TYPE)
|
|
14
|
+
const IMAGE_SUBTYPES = (
|
|
15
|
+
Object.keys(R2_UPLOAD_EXTENSION_BY_MIME_TYPE) as R2UploadMimeType[]
|
|
16
|
+
).map(mime => mime.slice('image/'.length))
|
|
17
|
+
|
|
18
|
+
const R2_OBJECT_KEY_RE = new RegExp(
|
|
19
|
+
`^uploads/organizations/[a-z0-9_-]+/[a-zA-Z0-9._-]+\\.(${IMAGE_EXTENSIONS.join('|')})$`
|
|
20
|
+
)
|
|
21
|
+
const DATA_IMAGE_URL_RE = new RegExp(
|
|
22
|
+
`^data:image/(?:${IMAGE_SUBTYPES.join('|')});base64,[A-Za-z0-9+/]+={0,2}$`
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
/** True when `value` is an owned-upload key (`uploads/organizations/<id>/<file>.<ext>`). */
|
|
26
|
+
export function isR2ObjectKey(value: string): boolean {
|
|
27
|
+
return R2_OBJECT_KEY_RE.test(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when `value` is a `data:image/*;base64,...` URL of an allowed MIME type. */
|
|
31
|
+
export function isDataImageUrl(value: string): boolean {
|
|
32
|
+
return DATA_IMAGE_URL_RE.test(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when `value` is an R2 object key or a data image URL. */
|
|
36
|
+
export function isStoredImageValue(value: string): boolean {
|
|
37
|
+
return isR2ObjectKey(value) || isDataImageUrl(value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Persist rules after format checks.
|
|
42
|
+
*
|
|
43
|
+
* R2 keys are always eligible (ownership is a separate DB check). Data URLs
|
|
44
|
+
* are only eligible when R2 is off; otherwise they skip the upload tracking
|
|
45
|
+
* and inline megabytes into every page that shows the image.
|
|
46
|
+
*/
|
|
47
|
+
export function canPersistImageValue(
|
|
48
|
+
value: string,
|
|
49
|
+
r2Configured: boolean
|
|
50
|
+
): boolean {
|
|
51
|
+
if (isR2ObjectKey(value)) return true
|
|
52
|
+
if (isDataImageUrl(value)) return !r2Configured
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
@@ -1,16 +1,26 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
1
3
|
import nodemailer, { SentMessageInfo } from 'nodemailer'
|
|
2
4
|
import { env } from '~/env'
|
|
5
|
+
import { AppError, AppErrorCode } from './errors'
|
|
3
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Configure the email transporter using environment variables.
|
|
9
|
+
* Uses nodemailer for sending emails via SMTP.
|
|
10
|
+
*/
|
|
4
11
|
const transporter = nodemailer.createTransport({
|
|
5
12
|
host: env.EMAIL_SERVER_HOST,
|
|
6
|
-
port:
|
|
7
|
-
secure:
|
|
13
|
+
port: env.EMAIL_SERVER_PORT,
|
|
14
|
+
secure: env.EMAIL_SERVER_PORT === 465, // Use secure for port 465, false for other ports
|
|
8
15
|
auth: {
|
|
9
16
|
user: env.EMAIL_SERVER_USER,
|
|
10
17
|
pass: env.EMAIL_SERVER_PASSWORD
|
|
11
18
|
}
|
|
12
19
|
})
|
|
13
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Interface for the email payload
|
|
23
|
+
*/
|
|
14
24
|
interface SendEmailParams {
|
|
15
25
|
to: string
|
|
16
26
|
subject: string
|
|
@@ -18,6 +28,17 @@ interface SendEmailParams {
|
|
|
18
28
|
html?: string
|
|
19
29
|
}
|
|
20
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Sends an email using the configured transporter.
|
|
33
|
+
*
|
|
34
|
+
* @param {SendEmailParams} params - The email parameters.
|
|
35
|
+
* @param {string} params.to - The recipient's email address.
|
|
36
|
+
* @param {string} params.subject - The subject of the email.
|
|
37
|
+
* @param {string} [params.text] - The plain text content of the email.
|
|
38
|
+
* @param {string} [params.html] - The HTML content of the email (optional).
|
|
39
|
+
* @returns {Promise<any>} Resolves when the email is sent successfully.
|
|
40
|
+
* @throws {Error} If sending the email fails.
|
|
41
|
+
*/
|
|
21
42
|
export const sendEmail = async ({
|
|
22
43
|
to,
|
|
23
44
|
subject,
|
|
@@ -38,6 +59,9 @@ export const sendEmail = async ({
|
|
|
38
59
|
return info
|
|
39
60
|
} catch (error) {
|
|
40
61
|
console.error('Error sending email:', error)
|
|
41
|
-
throw new
|
|
62
|
+
throw new AppError(
|
|
63
|
+
AppErrorCode.INTERNAL,
|
|
64
|
+
'The email was not sent. Try again.'
|
|
65
|
+
)
|
|
42
66
|
}
|
|
43
67
|
}
|