create-ej-app 25.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 +83 -28
- 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 -0
- package/template/nextjs/next.config.ts +8 -1
- package/template/nextjs/package.json +47 -40
- 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 +8 -0
- package/template/saas-starter/.env.example +25 -0
- package/template/saas-starter/.vscode/extensions.json +11 -0
- package/template/saas-starter/.vscode/settings.json +3 -0
- package/template/saas-starter/AGENTS.md +302 -0
- package/template/saas-starter/CLAUDE.md +1 -0
- package/template/saas-starter/Dockerfile +15 -0
- package/template/saas-starter/README.md +125 -0
- package/template/saas-starter/bunfig.toml +3 -0
- package/template/saas-starter/components.json +25 -0
- package/template/saas-starter/drizzle.config.ts +10 -0
- package/template/saas-starter/eslint.config.mjs +24 -0
- package/template/saas-starter/gitignore +14 -0
- package/template/saas-starter/next.config.ts +32 -0
- package/template/saas-starter/package.json +97 -0
- package/template/saas-starter/postcss.config.mjs +7 -0
- package/template/saas-starter/prettier.config.mjs +11 -0
- package/template/saas-starter/public/logo-dark.svg +4 -0
- package/template/saas-starter/public/logo.svg +4 -0
- package/template/saas-starter/public/placeholder.svg +1 -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 +183 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- 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 +53 -0
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +60 -0
- 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 +377 -0
- 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 +216 -0
- 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 +189 -0
- 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 +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +31 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +92 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +357 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +130 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +222 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +115 -0
- 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 +168 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +604 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +33 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +55 -0
- 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 +31 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +26 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +30 -0
- 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 +37 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +200 -0
- package/template/saas-starter/src/app/(app)/app/layout.tsx +64 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +117 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +40 -0
- package/template/saas-starter/src/app/(app)/app/page.tsx +76 -0
- 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 +131 -0
- package/template/saas-starter/src/app/(auth)/login/page.tsx +105 -0
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +137 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +94 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +153 -0
- 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 +48 -0
- package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +24 -0
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +63 -0
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +41 -0
- 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/api/auth/[...all]/route.ts +4 -0
- package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
- package/template/saas-starter/src/app/error.tsx +37 -0
- package/template/saas-starter/src/app/favicon.ico +0 -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 +269 -0
- package/template/saas-starter/src/app/layout.tsx +77 -0
- package/template/saas-starter/src/app/not-found.tsx +49 -0
- package/template/saas-starter/src/app/robots.ts +36 -0
- package/template/saas-starter/src/app/sitemap.ts +21 -0
- package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +70 -0
- package/template/saas-starter/src/components/data-table/enum.ts +5 -0
- package/template/saas-starter/src/components/data-table/features.ts +49 -0
- package/template/saas-starter/src/components/data-table/index.tsx +961 -0
- package/template/saas-starter/src/components/data-table/types.ts +76 -0
- package/template/saas-starter/src/components/error.tsx +71 -0
- package/template/saas-starter/src/components/file-upload.tsx +254 -0
- package/template/saas-starter/src/components/loader.tsx +41 -0
- package/template/saas-starter/src/components/logo.tsx +49 -0
- package/template/saas-starter/src/components/navigation-search.tsx +140 -0
- package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
- package/template/saas-starter/src/components/page-heading.tsx +38 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +51 -0
- package/template/saas-starter/src/components/route-error.tsx +55 -0
- package/template/saas-starter/src/components/screen-size.tsx +76 -0
- package/template/saas-starter/src/components/site-footer.tsx +39 -0
- 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 +115 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +75 -0
- package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
- package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
- package/template/saas-starter/src/components/ui/badge.tsx +54 -0
- package/template/saas-starter/src/components/ui/button.tsx +69 -0
- package/template/saas-starter/src/components/ui/card.tsx +103 -0
- package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
- package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
- package/template/saas-starter/src/components/ui/command.tsx +192 -0
- package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
- package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/saas-starter/src/components/ui/field.tsx +238 -0
- package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
- package/template/saas-starter/src/components/ui/input.tsx +19 -0
- package/template/saas-starter/src/components/ui/label.tsx +24 -0
- package/template/saas-starter/src/components/ui/popover.tsx +89 -0
- package/template/saas-starter/src/components/ui/select.tsx +195 -0
- package/template/saas-starter/src/components/ui/separator.tsx +28 -0
- package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
- package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
- package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
- package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
- package/template/saas-starter/src/components/ui/switch.tsx +33 -0
- package/template/saas-starter/src/components/ui/table.tsx +116 -0
- package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
- package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
- package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
- package/template/saas-starter/src/components/user-menu.tsx +213 -0
- package/template/saas-starter/src/db/common.ts +63 -0
- package/template/saas-starter/src/db/enums.ts +50 -0
- package/template/saas-starter/src/db/index.ts +60 -0
- package/template/saas-starter/src/db/relations.ts +112 -0
- package/template/saas-starter/src/db/schema.ts +342 -0
- package/template/saas-starter/src/emails/components/email-layout.tsx +79 -0
- package/template/saas-starter/src/emails/invitation.tsx +69 -0
- package/template/saas-starter/src/emails/magic-link.tsx +51 -0
- package/template/saas-starter/src/emails/test.tsx +34 -0
- package/template/saas-starter/src/emails/welcome.tsx +37 -0
- package/template/saas-starter/src/env.ts +66 -0
- package/template/saas-starter/src/hooks/use-mobile.ts +21 -0
- package/template/saas-starter/src/instrumentation.ts +44 -0
- package/template/saas-starter/src/lib/actions.ts +55 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +63 -0
- package/template/saas-starter/src/lib/app-navigation.ts +63 -0
- package/template/saas-starter/src/lib/auth-client.ts +34 -0
- package/template/saas-starter/src/lib/auth-permissions.ts +61 -0
- package/template/saas-starter/src/lib/auth.ts +312 -0
- package/template/saas-starter/src/lib/billing.ts +152 -0
- package/template/saas-starter/src/lib/callback-url.ts +28 -0
- package/template/saas-starter/src/lib/cn.ts +4 -0
- package/template/saas-starter/src/lib/constants.ts +91 -0
- package/template/saas-starter/src/lib/delay.ts +12 -0
- package/template/saas-starter/src/lib/email-service.ts +118 -0
- package/template/saas-starter/src/lib/errors.ts +74 -0
- package/template/saas-starter/src/lib/format-currency.ts +25 -0
- package/template/saas-starter/src/lib/format-date.ts +75 -0
- package/template/saas-starter/src/lib/image-url.ts +54 -0
- package/template/saas-starter/src/lib/nodemailer.ts +67 -0
- package/template/saas-starter/src/lib/organization-access.ts +73 -0
- package/template/saas-starter/src/lib/otel.ts +117 -0
- package/template/saas-starter/src/lib/plan-limits.ts +37 -0
- package/template/saas-starter/src/lib/plans.ts +90 -0
- package/template/saas-starter/src/lib/rbac.ts +44 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +126 -0
- package/template/saas-starter/src/lib/safe-action.ts +187 -0
- 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 +20 -0
- package/template/saas-starter/src/lib/storage.ts +311 -0
- package/template/saas-starter/src/lib/stripe.ts +9 -0
- package/template/saas-starter/src/lib/toast-message.ts +101 -0
- package/template/saas-starter/src/lib/validations.ts +95 -0
- 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/reset-db.ts +22 -0
- package/template/saas-starter/src/scripts/seed.ts +85 -0
- package/template/saas-starter/src/scripts/setup-db.ts +72 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +206 -0
- package/template/saas-starter/src/scripts/test-mail.ts +43 -0
- 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 +52 -0
- package/cli.js +0 -143
- 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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DeleteObjectCommand,
|
|
5
|
+
PutObjectCommand,
|
|
6
|
+
S3Client
|
|
7
|
+
} from '@aws-sdk/client-s3'
|
|
8
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
9
|
+
import { createId } from '@paralleldrive/cuid2'
|
|
10
|
+
import { and, eq, lt } from 'drizzle-orm'
|
|
11
|
+
import { db } from '~/db'
|
|
12
|
+
import { fileUploadsTable } from '~/db/schema'
|
|
13
|
+
import { env } from '~/env'
|
|
14
|
+
import {
|
|
15
|
+
PRESIGNED_URL_TTL_SECONDS,
|
|
16
|
+
R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
|
|
17
|
+
TEMP_CLEANUP_THRESHOLD_MS,
|
|
18
|
+
type R2UploadMimeType
|
|
19
|
+
} from './constants'
|
|
20
|
+
import { AppError, AppErrorCode } from './errors'
|
|
21
|
+
import {
|
|
22
|
+
canPersistImageValue,
|
|
23
|
+
isDataImageUrl,
|
|
24
|
+
isR2ObjectKey
|
|
25
|
+
} from './image-url'
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
The R2 bucket needs this CORS policy for browser uploads:
|
|
29
|
+
[
|
|
30
|
+
{
|
|
31
|
+
"AllowedOrigins": ["http://localhost:3000"],
|
|
32
|
+
"AllowedMethods": ["PUT"],
|
|
33
|
+
"AllowedHeaders": ["content-type", "content-length"]
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** True when all five R2 env vars are set. When false, uploads fall back to base64 data URLs. */
|
|
39
|
+
export function isR2Configured(): boolean {
|
|
40
|
+
return !!(
|
|
41
|
+
env.R2_ACCOUNT_ID &&
|
|
42
|
+
env.R2_ACCESS_KEY_ID &&
|
|
43
|
+
env.R2_SECRET_ACCESS_KEY &&
|
|
44
|
+
env.R2_BUCKET_NAME &&
|
|
45
|
+
env.R2_PUBLIC_URL
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let _r2Client: S3Client | null = null
|
|
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
|
+
*/
|
|
56
|
+
function getR2Client(): S3Client {
|
|
57
|
+
if (_r2Client) return _r2Client
|
|
58
|
+
|
|
59
|
+
if (!isR2Configured()) {
|
|
60
|
+
// A configuration bug, not a customer error: masked and recorded.
|
|
61
|
+
throw new Error('R2 storage is not configured.')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_r2Client = new S3Client({
|
|
65
|
+
region: 'auto',
|
|
66
|
+
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
67
|
+
credentials: {
|
|
68
|
+
accessKeyId: env.R2_ACCESS_KEY_ID!,
|
|
69
|
+
secretAccessKey: env.R2_SECRET_ACCESS_KEY!
|
|
70
|
+
},
|
|
71
|
+
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
72
|
+
responseChecksumValidation: 'WHEN_REQUIRED'
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return _r2Client
|
|
76
|
+
}
|
|
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
|
+
*/
|
|
119
|
+
export async function deleteFile(key: string): Promise<void> {
|
|
120
|
+
await getR2Client().send(
|
|
121
|
+
new DeleteObjectCommand({ Bucket: env.R2_BUCKET_NAME, Key: key })
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
await db.delete(fileUploadsTable).where(eq(fileUploadsTable.key, key))
|
|
125
|
+
}
|
|
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
|
+
*/
|
|
135
|
+
export async function generateUploadUrl(params: {
|
|
136
|
+
filename: string
|
|
137
|
+
mimeType: R2UploadMimeType
|
|
138
|
+
size: number
|
|
139
|
+
uploadedBy: string
|
|
140
|
+
organizationId: string
|
|
141
|
+
}): Promise<{ uploadUrl: string; key: string }> {
|
|
142
|
+
const ext = R2_UPLOAD_EXTENSION_BY_MIME_TYPE[params.mimeType]
|
|
143
|
+
const fileId = createId()
|
|
144
|
+
const key = `uploads/organizations/${params.organizationId}/${fileId}.${ext}`
|
|
145
|
+
|
|
146
|
+
const uploadUrl = await getSignedUrl(
|
|
147
|
+
getR2Client(),
|
|
148
|
+
new PutObjectCommand({
|
|
149
|
+
Bucket: env.R2_BUCKET_NAME!,
|
|
150
|
+
Key: key,
|
|
151
|
+
ContentType: params.mimeType,
|
|
152
|
+
ContentLength: params.size
|
|
153
|
+
}),
|
|
154
|
+
{
|
|
155
|
+
expiresIn: PRESIGNED_URL_TTL_SECONDS,
|
|
156
|
+
signableHeaders: new Set(['content-type'])
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
await db.insert(fileUploadsTable).values({
|
|
161
|
+
id: `file_upload_${fileId}`,
|
|
162
|
+
key,
|
|
163
|
+
originalName: params.filename,
|
|
164
|
+
mimeType: params.mimeType,
|
|
165
|
+
size: params.size,
|
|
166
|
+
isTemp: true,
|
|
167
|
+
uploadedBy: params.uploadedBy,
|
|
168
|
+
organizationId: params.organizationId
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return { uploadUrl, key }
|
|
172
|
+
}
|
|
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
|
+
*/
|
|
181
|
+
export async function confirmUpload(
|
|
182
|
+
newKey: string,
|
|
183
|
+
organizationId: string,
|
|
184
|
+
oldKey?: string | null
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
await assertOwnedR2Key(newKey, organizationId)
|
|
187
|
+
|
|
188
|
+
await db
|
|
189
|
+
.update(fileUploadsTable)
|
|
190
|
+
.set({ isTemp: false })
|
|
191
|
+
.where(eq(fileUploadsTable.key, newKey))
|
|
192
|
+
|
|
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
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await db
|
|
234
|
+
.delete(fileUploadsTable)
|
|
235
|
+
.where(eq(fileUploadsTable.organizationId, organizationId))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** True when `value` is an R2 object key (`uploads/organizations/<id>/<file>.<ext>`). */
|
|
239
|
+
export function isR2Key(value: string | null | undefined): value is string {
|
|
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
|
+
)
|
|
256
|
+
}
|
|
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
|
+
*/
|
|
265
|
+
export function resolveImageUrl(
|
|
266
|
+
value: string | null | undefined
|
|
267
|
+
): string | null {
|
|
268
|
+
if (!value) return null
|
|
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
|
|
275
|
+
}
|
|
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
|
+
*/
|
|
282
|
+
export async function cleanupTempUploads(): Promise<{
|
|
283
|
+
deleted: number
|
|
284
|
+
errors: number
|
|
285
|
+
}> {
|
|
286
|
+
const threshold = new Date(Date.now() - TEMP_CLEANUP_THRESHOLD_MS)
|
|
287
|
+
|
|
288
|
+
const stale = await db
|
|
289
|
+
.select({ key: fileUploadsTable.key })
|
|
290
|
+
.from(fileUploadsTable)
|
|
291
|
+
.where(
|
|
292
|
+
and(
|
|
293
|
+
eq(fileUploadsTable.isTemp, true),
|
|
294
|
+
lt(fileUploadsTable.createdAt, threshold)
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
let deleted = 0
|
|
299
|
+
let errors = 0
|
|
300
|
+
|
|
301
|
+
for (const file of stale) {
|
|
302
|
+
try {
|
|
303
|
+
await deleteFile(file.key)
|
|
304
|
+
deleted++
|
|
305
|
+
} catch {
|
|
306
|
+
errors++
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { deleted, errors }
|
|
311
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import Stripe from 'stripe'
|
|
4
|
+
import { env } from '~/env'
|
|
5
|
+
|
|
6
|
+
/** Pinned to the API version this `stripe` SDK release was built for. Bump both together. */
|
|
7
|
+
export const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, {
|
|
8
|
+
apiVersion: '2026-08-26.dahlia'
|
|
9
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { toast } from 'sonner'
|
|
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
|
+
*/
|
|
15
|
+
export const toastSuccessMessage = (message: string) =>
|
|
16
|
+
toast.success(message, {
|
|
17
|
+
richColors: true,
|
|
18
|
+
position: 'top-center'
|
|
19
|
+
})
|
|
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
|
+
*/
|
|
33
|
+
export const toastErrorMessage = (message?: string) =>
|
|
34
|
+
toast.error(message || DEFAULT_ERROR, {
|
|
35
|
+
richColors: true,
|
|
36
|
+
position: 'top-center'
|
|
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
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Zod fragments for action input and forms.
|
|
3
|
+
*
|
|
4
|
+
* Client-safe: no env, DB, or server-only imports, so forms can reuse the
|
|
5
|
+
* exact schema their action validates with.
|
|
6
|
+
*/
|
|
7
|
+
import z from 'zod'
|
|
8
|
+
import {
|
|
9
|
+
MAX_COMMENT_LENGTH,
|
|
10
|
+
MAX_IMAGE_URL_LENGTH,
|
|
11
|
+
MAX_MESSAGE_LENGTH,
|
|
12
|
+
MAX_RATING,
|
|
13
|
+
MAX_SLUG_LENGTH,
|
|
14
|
+
MIN_RATING
|
|
15
|
+
} from '~/lib/constants'
|
|
16
|
+
import { isStoredImageValue } from '~/lib/image-url'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Phone number: 7 to 15 digits once separators are removed (E.164 maximum).
|
|
20
|
+
* Allowed characters are digits, `+`, `-`, `()`, `.`, and spaces.
|
|
21
|
+
*/
|
|
22
|
+
export const phoneValidation = z
|
|
23
|
+
.string()
|
|
24
|
+
.trim()
|
|
25
|
+
.max(20)
|
|
26
|
+
.regex(/^[\d+\-().\s]+$/, 'Enter a valid phone number.')
|
|
27
|
+
.refine(value => {
|
|
28
|
+
const digits = value.replace(/\D/g, '').length
|
|
29
|
+
return digits >= 7 && digits <= 15
|
|
30
|
+
}, 'Enter a phone number with 7 to 15 digits.')
|
|
31
|
+
|
|
32
|
+
/** Email address, trimmed and lowercased. */
|
|
33
|
+
export const emailValidation = z.email().trim().toLowerCase()
|
|
34
|
+
|
|
35
|
+
/** Required short string, 1 to 255 characters after trimming. */
|
|
36
|
+
export const stringValidation = z.string().trim().min(1).max(255)
|
|
37
|
+
|
|
38
|
+
/** Optional short string (max 255). Empty string is allowed after trim. */
|
|
39
|
+
export const optionalStringValidation = z.string().trim().max(255).optional()
|
|
40
|
+
|
|
41
|
+
/** Required body for support tickets and similar messages. */
|
|
42
|
+
export const requiredMessageValidation = z
|
|
43
|
+
.string()
|
|
44
|
+
.trim()
|
|
45
|
+
.min(1, 'Enter a message.')
|
|
46
|
+
.max(MAX_MESSAGE_LENGTH)
|
|
47
|
+
|
|
48
|
+
/** Required feedback comment. */
|
|
49
|
+
export const requiredCommentValidation = z
|
|
50
|
+
.string()
|
|
51
|
+
.trim()
|
|
52
|
+
.min(1, 'Write your feedback.')
|
|
53
|
+
.max(MAX_COMMENT_LENGTH)
|
|
54
|
+
|
|
55
|
+
/** Star rating from 1 to 5. */
|
|
56
|
+
export const ratingValidation = z.number().int().min(MIN_RATING).max(MAX_RATING)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* R2 object key or `data:image/*;base64,...` URL. Empty after trim is allowed
|
|
60
|
+
* so a cleared field still parses; the action treats it as "no image".
|
|
61
|
+
*/
|
|
62
|
+
export const imageUrlValidation = z
|
|
63
|
+
.string()
|
|
64
|
+
.trim()
|
|
65
|
+
.max(MAX_IMAGE_URL_LENGTH)
|
|
66
|
+
.refine(value => value === '' || isStoredImageValue(value), {
|
|
67
|
+
message: 'Upload an image file. External URLs are not stored.'
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* URL slug: lowercase letters, numbers, and single hyphens between segments.
|
|
72
|
+
* Rejects empty strings, path segments, and query characters.
|
|
73
|
+
*/
|
|
74
|
+
export const slugValidation = z
|
|
75
|
+
.string()
|
|
76
|
+
.trim()
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.min(1, 'Enter a slug.')
|
|
79
|
+
.max(MAX_SLUG_LENGTH)
|
|
80
|
+
.regex(
|
|
81
|
+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
82
|
+
'Use only lowercase letters, numbers, and hyphens.'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a URL slug from a display name.
|
|
87
|
+
* Returns an empty string when nothing remains, such as an emoji-only name.
|
|
88
|
+
*/
|
|
89
|
+
export function slugifyName(name: string): string {
|
|
90
|
+
return name
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
93
|
+
.replace(/(^-|-$)/g, '')
|
|
94
|
+
.slice(0, MAX_SLUG_LENGTH)
|
|
95
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Proxy middleware function
|
|
5
|
+
*
|
|
6
|
+
* Forwards the request pathname (+ search) as `x-pathname` so server layouts
|
|
7
|
+
* can build a same-origin login callback without losing deep links.
|
|
8
|
+
* @returns NextResponse that continues the request chain
|
|
9
|
+
*/
|
|
10
|
+
export function proxy(request: NextRequest) {
|
|
11
|
+
const requestHeaders = new Headers(request.headers)
|
|
12
|
+
requestHeaders.set(
|
|
13
|
+
'x-pathname',
|
|
14
|
+
`${request.nextUrl.pathname}${request.nextUrl.search}`
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
return NextResponse.next({
|
|
18
|
+
request: { headers: requestHeaders }
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Middleware configuration
|
|
24
|
+
*
|
|
25
|
+
* Defines which routes this middleware should be applied to using the matcher pattern.
|
|
26
|
+
*/
|
|
27
|
+
export const config = {
|
|
28
|
+
matcher: [
|
|
29
|
+
{
|
|
30
|
+
/**
|
|
31
|
+
* Route matching pattern
|
|
32
|
+
*
|
|
33
|
+
* This regex pattern matches all routes EXCEPT:
|
|
34
|
+
* - /_next/static - Next.js static assets
|
|
35
|
+
* - /_next/image - Next.js image optimization
|
|
36
|
+
* - /favicon.ico - Browser favicon
|
|
37
|
+
* - /.well-known/workflow/ - Internal workflow paths
|
|
38
|
+
*
|
|
39
|
+
* Excluding these paths ensures that:
|
|
40
|
+
* 1. Static assets load efficiently without middleware overhead
|
|
41
|
+
* 2. Image optimization is not interrupted
|
|
42
|
+
* 3. Workflow execution and resumption function correctly
|
|
43
|
+
*/
|
|
44
|
+
source:
|
|
45
|
+
'/((?!_next/static|_next/image|favicon.ico|.well-known/workflow/).*)'
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Deletes uploads that were never confirmed, such as a logo
|
|
3
|
+
* picked and then abandoned. Run it on a schedule (for example hourly) with
|
|
4
|
+
* `bun run storage:cleanup`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { cleanupTempUploads, isR2Configured } from '~/lib/storage'
|
|
8
|
+
|
|
9
|
+
if (import.meta.main) {
|
|
10
|
+
if (!isR2Configured()) {
|
|
11
|
+
console.info('R2 is not configured; there are no temporary uploads.')
|
|
12
|
+
process.exit(0)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
cleanupTempUploads()
|
|
16
|
+
.then(({ deleted, errors }) => {
|
|
17
|
+
console.info(`Deleted ${deleted} temporary uploads; ${errors} failed.`)
|
|
18
|
+
process.exit(errors ? 1 : 0)
|
|
19
|
+
})
|
|
20
|
+
.catch(err => {
|
|
21
|
+
console.error('Error cleaning up temporary uploads:', err)
|
|
22
|
+
process.exit(1)
|
|
23
|
+
})
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
import { db } from '~/db'
|
|
3
|
+
import { env } from '~/env'
|
|
4
|
+
|
|
5
|
+
const resetDb = async () => {
|
|
6
|
+
if (env.APP_ENV === 'production') {
|
|
7
|
+
throw new Error('Cannot reset database in production environment.')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
await db.execute(sql`DROP SCHEMA IF EXISTS public CASCADE;`)
|
|
11
|
+
await db.execute(sql`CREATE SCHEMA public;`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (import.meta.main) {
|
|
15
|
+
try {
|
|
16
|
+
await resetDb()
|
|
17
|
+
process.exit(0)
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error('Error running reset-db:', err)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Seeds a local database with a superadmin, a regular user,
|
|
3
|
+
* and one organization on the free plan.
|
|
4
|
+
*
|
|
5
|
+
* Sign in with a magic link sent to either seeded address, so point them at
|
|
6
|
+
* inboxes you control. A successful run writes a `seed_runs` marker and later
|
|
7
|
+
* runs do nothing; reset the database to seed again. Everything runs in one
|
|
8
|
+
* transaction, so a failure leaves no partial data.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { db } from '~/db'
|
|
12
|
+
import {
|
|
13
|
+
SEED_RUN_MARKER_ID,
|
|
14
|
+
membersTable,
|
|
15
|
+
organizationSubscriptionsTable,
|
|
16
|
+
organizationsTable,
|
|
17
|
+
seedRunsTable,
|
|
18
|
+
usersTable
|
|
19
|
+
} from '~/db/schema'
|
|
20
|
+
|
|
21
|
+
/** Seeds `database` once. Returns `skipped: true` when a seed already ran. */
|
|
22
|
+
export async function seedDatabase(
|
|
23
|
+
database: typeof db = db
|
|
24
|
+
): Promise<{ skipped: boolean }> {
|
|
25
|
+
const [existingSeedRun] = await database
|
|
26
|
+
.select({ id: seedRunsTable.id })
|
|
27
|
+
.from(seedRunsTable)
|
|
28
|
+
.limit(1)
|
|
29
|
+
|
|
30
|
+
if (existingSeedRun) return { skipped: true }
|
|
31
|
+
|
|
32
|
+
await database.transaction(async tx => {
|
|
33
|
+
const superadminId = usersTable.id.defaultFn!()
|
|
34
|
+
const userId = usersTable.id.defaultFn!()
|
|
35
|
+
const organizationId = organizationsTable.id.defaultFn!()
|
|
36
|
+
|
|
37
|
+
await tx.insert(usersTable).values([
|
|
38
|
+
{
|
|
39
|
+
id: superadminId,
|
|
40
|
+
name: 'Super Admin',
|
|
41
|
+
email: 'superadmin@example.com',
|
|
42
|
+
emailVerified: true,
|
|
43
|
+
role: 'superadmin'
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: userId,
|
|
47
|
+
name: 'Demo User',
|
|
48
|
+
email: 'user@example.com',
|
|
49
|
+
emailVerified: true,
|
|
50
|
+
role: 'user'
|
|
51
|
+
}
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
await tx.insert(organizationsTable).values({
|
|
55
|
+
id: organizationId,
|
|
56
|
+
name: 'Demo Organization',
|
|
57
|
+
slug: 'demo-organization'
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
await tx.insert(membersTable).values({
|
|
61
|
+
organizationId,
|
|
62
|
+
userId,
|
|
63
|
+
role: 'owner'
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await tx.insert(organizationSubscriptionsTable).values({ organizationId })
|
|
67
|
+
|
|
68
|
+
// Marker last, so a failed seed never blocks a retry.
|
|
69
|
+
await tx.insert(seedRunsTable).values({ id: SEED_RUN_MARKER_ID })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return { skipped: false }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (import.meta.main) {
|
|
76
|
+
seedDatabase(db)
|
|
77
|
+
.then(result => {
|
|
78
|
+
if (result.skipped) console.info('Database already seeded; skipping.')
|
|
79
|
+
process.exit(0)
|
|
80
|
+
})
|
|
81
|
+
.catch(err => {
|
|
82
|
+
console.error('Error running seed:', err)
|
|
83
|
+
process.exit(1)
|
|
84
|
+
})
|
|
85
|
+
}
|