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,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Installs Postgres functions and triggers that Drizzle cannot
|
|
3
|
+
* manage.
|
|
4
|
+
*
|
|
5
|
+
* Support tickets get a `TICKET-000001` number from a counter table. The
|
|
6
|
+
* counter function upserts atomically, and a BEFORE INSERT trigger fills in
|
|
7
|
+
* `ticket_number`, so inserts never pass the number themselves.
|
|
8
|
+
*
|
|
9
|
+
* Everything is CREATE OR REPLACE, so the script is safe to re-run.
|
|
10
|
+
* `bun run db:push` runs it before `drizzle-kit push`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { sql } from 'drizzle-orm'
|
|
14
|
+
import { db } from '~/db'
|
|
15
|
+
|
|
16
|
+
/** Installs every custom function and trigger on the given client. */
|
|
17
|
+
export async function setupDatabase(database: typeof db = db): Promise<void> {
|
|
18
|
+
// INSERT ... ON CONFLICT ... DO UPDATE is one atomic statement, so two
|
|
19
|
+
// concurrent calls never receive the same number.
|
|
20
|
+
await database.execute(sql`
|
|
21
|
+
CREATE OR REPLACE FUNCTION get_next_support_ticket_number()
|
|
22
|
+
RETURNS TEXT AS $$
|
|
23
|
+
DECLARE
|
|
24
|
+
new_number INTEGER;
|
|
25
|
+
BEGIN
|
|
26
|
+
INSERT INTO support_ticket_counter (id, counter)
|
|
27
|
+
VALUES (1, 1)
|
|
28
|
+
ON CONFLICT (id)
|
|
29
|
+
DO UPDATE SET counter = support_ticket_counter.counter + 1
|
|
30
|
+
RETURNING counter INTO new_number;
|
|
31
|
+
|
|
32
|
+
RETURN 'TICKET-' || LPAD(new_number::TEXT, 6, '0');
|
|
33
|
+
END;
|
|
34
|
+
$$ LANGUAGE plpgsql;
|
|
35
|
+
`)
|
|
36
|
+
|
|
37
|
+
// An explicit ticket_number (seeding, migrations) skips the counter.
|
|
38
|
+
await database.execute(sql`
|
|
39
|
+
CREATE OR REPLACE FUNCTION trg_set_support_ticket_number()
|
|
40
|
+
RETURNS TRIGGER AS $$
|
|
41
|
+
BEGIN
|
|
42
|
+
IF NEW.ticket_number IS NULL OR NEW.ticket_number = '' THEN
|
|
43
|
+
NEW.ticket_number := get_next_support_ticket_number();
|
|
44
|
+
END IF;
|
|
45
|
+
RETURN NEW;
|
|
46
|
+
END;
|
|
47
|
+
$$ LANGUAGE plpgsql;
|
|
48
|
+
`)
|
|
49
|
+
|
|
50
|
+
// On a fresh database this runs before drizzle-kit push, so create a stub
|
|
51
|
+
// table for the trigger to attach to. The push then adds the real columns.
|
|
52
|
+
await database.execute(sql`
|
|
53
|
+
CREATE TABLE IF NOT EXISTS support_tickets (
|
|
54
|
+
id TEXT PRIMARY KEY
|
|
55
|
+
);
|
|
56
|
+
`)
|
|
57
|
+
|
|
58
|
+
await database.execute(sql`
|
|
59
|
+
CREATE OR REPLACE TRIGGER set_support_ticket_number
|
|
60
|
+
BEFORE INSERT ON support_tickets
|
|
61
|
+
FOR EACH ROW EXECUTE FUNCTION trg_set_support_ticket_number();
|
|
62
|
+
`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (import.meta.main) {
|
|
66
|
+
setupDatabase(db)
|
|
67
|
+
.then(() => process.exit(0))
|
|
68
|
+
.catch(err => {
|
|
69
|
+
console.error('Error running setup-db:', err)
|
|
70
|
+
process.exit(1)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Creates the Stripe products, prices, and billing portal
|
|
3
|
+
* configuration for every paid plan in `~/lib/plans`.
|
|
4
|
+
*
|
|
5
|
+
* Existing prices are reused. New IDs are written to `stripe.txt`; copy them
|
|
6
|
+
* into `.env`, then delete the file.
|
|
7
|
+
*/
|
|
8
|
+
import { writeFile } from 'fs/promises'
|
|
9
|
+
import type Stripe from 'stripe'
|
|
10
|
+
import { env } from '~/env'
|
|
11
|
+
import { PLANS } from '~/lib/plans'
|
|
12
|
+
import { stripeClient } from '~/lib/stripe'
|
|
13
|
+
|
|
14
|
+
const checkStripePriceExists = async (priceId: string) => {
|
|
15
|
+
try {
|
|
16
|
+
await stripeClient.prices.retrieve(priceId)
|
|
17
|
+
return true
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (
|
|
20
|
+
err &&
|
|
21
|
+
typeof err === 'object' &&
|
|
22
|
+
'code' in err &&
|
|
23
|
+
(err as { code?: string }).code === 'resource_missing'
|
|
24
|
+
) {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
throw err
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const createStripeProduct = async (name: string, planId: string) => {
|
|
32
|
+
const newProduct = await stripeClient.products.create({
|
|
33
|
+
name,
|
|
34
|
+
metadata: { planId }
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return newProduct.id
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const createStripePrice = async (
|
|
41
|
+
productId: string,
|
|
42
|
+
planId: string,
|
|
43
|
+
interval: 'month' | 'year',
|
|
44
|
+
amount: number,
|
|
45
|
+
currency: string
|
|
46
|
+
) => {
|
|
47
|
+
const newPrice = await stripeClient.prices.create({
|
|
48
|
+
product: productId,
|
|
49
|
+
currency,
|
|
50
|
+
unit_amount: amount,
|
|
51
|
+
recurring: { interval },
|
|
52
|
+
metadata: { planId }
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return newPrice.id
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type PaidPlanPrices = {
|
|
59
|
+
planId: string
|
|
60
|
+
productId: string
|
|
61
|
+
monthlyPriceId: string
|
|
62
|
+
annualPriceId: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const setupStripePlans = async () => {
|
|
66
|
+
let newEnvString = ''
|
|
67
|
+
const paidPlans: PaidPlanPrices[] = []
|
|
68
|
+
|
|
69
|
+
for (const plan of PLANS) {
|
|
70
|
+
if (!plan.stripePrice) continue
|
|
71
|
+
|
|
72
|
+
const monthlyExists = await checkStripePriceExists(
|
|
73
|
+
plan.stripePrice.monthly.id
|
|
74
|
+
)
|
|
75
|
+
const annualExists = await checkStripePriceExists(
|
|
76
|
+
plan.stripePrice.annual.id
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if ((!monthlyExists && annualExists) || (monthlyExists && !annualExists)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Inconsistent Stripe price setup for plan ${plan.name}. Monthly exists: ${monthlyExists}, Annual exists: ${annualExists}`
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (monthlyExists && annualExists) {
|
|
86
|
+
const price = await stripeClient.prices.retrieve(
|
|
87
|
+
plan.stripePrice.monthly.id
|
|
88
|
+
)
|
|
89
|
+
paidPlans.push({
|
|
90
|
+
planId: plan.id,
|
|
91
|
+
productId:
|
|
92
|
+
typeof price.product === 'string' ? price.product : price.product.id,
|
|
93
|
+
monthlyPriceId: plan.stripePrice.monthly.id,
|
|
94
|
+
annualPriceId: plan.stripePrice.annual.id
|
|
95
|
+
})
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const productId = await createStripeProduct(plan.name, plan.id)
|
|
100
|
+
const monthlyPriceId = await createStripePrice(
|
|
101
|
+
productId,
|
|
102
|
+
plan.id,
|
|
103
|
+
'month',
|
|
104
|
+
plan.stripePrice.monthly.amount,
|
|
105
|
+
plan.stripePrice.monthly.currency
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
newEnvString += `\nSTRIPE_PRICE_${plan.id.toUpperCase()}_MONTHLY="${monthlyPriceId}"`
|
|
109
|
+
|
|
110
|
+
const annualPriceId = await createStripePrice(
|
|
111
|
+
productId,
|
|
112
|
+
plan.id,
|
|
113
|
+
'year',
|
|
114
|
+
plan.stripePrice.annual.amount,
|
|
115
|
+
plan.stripePrice.annual.currency
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
newEnvString += `\nSTRIPE_PRICE_${plan.id.toUpperCase()}_ANNUAL="${annualPriceId}"`
|
|
119
|
+
|
|
120
|
+
paidPlans.push({
|
|
121
|
+
planId: plan.id,
|
|
122
|
+
productId,
|
|
123
|
+
monthlyPriceId,
|
|
124
|
+
annualPriceId
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const portalConfigId = await setupPortalConfiguration(paidPlans)
|
|
129
|
+
if (portalConfigId) {
|
|
130
|
+
newEnvString += `\nSTRIPE_PORTAL_CONFIGURATION_ID="${portalConfigId}"`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
newEnvString = newEnvString.trim()
|
|
134
|
+
|
|
135
|
+
if (newEnvString) {
|
|
136
|
+
await writeFile('stripe.txt', newEnvString)
|
|
137
|
+
console.info('Stripe setup complete. New values written to stripe.txt')
|
|
138
|
+
} else {
|
|
139
|
+
console.info('All Stripe plans and portal config already exist.')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const setupPortalConfiguration = async (paidPlans: PaidPlanPrices[]) => {
|
|
144
|
+
if (paidPlans.length === 0) return null
|
|
145
|
+
|
|
146
|
+
const params: Stripe.BillingPortal.ConfigurationCreateParams = {
|
|
147
|
+
features: {
|
|
148
|
+
customer_update: {
|
|
149
|
+
enabled: true,
|
|
150
|
+
allowed_updates: ['address', 'name', 'tax_id']
|
|
151
|
+
},
|
|
152
|
+
invoice_history: { enabled: true },
|
|
153
|
+
payment_method_update: { enabled: true },
|
|
154
|
+
subscription_cancel: {
|
|
155
|
+
enabled: true,
|
|
156
|
+
mode: 'at_period_end',
|
|
157
|
+
cancellation_reason: {
|
|
158
|
+
enabled: true,
|
|
159
|
+
options: [
|
|
160
|
+
'too_expensive',
|
|
161
|
+
'missing_features',
|
|
162
|
+
'switched_service',
|
|
163
|
+
'unused',
|
|
164
|
+
'other'
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
subscription_update: {
|
|
169
|
+
enabled: true,
|
|
170
|
+
default_allowed_updates: ['price'],
|
|
171
|
+
proration_behavior: 'create_prorations',
|
|
172
|
+
products: paidPlans.map(p => ({
|
|
173
|
+
product: p.productId,
|
|
174
|
+
prices: [p.monthlyPriceId, p.annualPriceId]
|
|
175
|
+
}))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const existingId = env.STRIPE_PORTAL_CONFIGURATION_ID
|
|
181
|
+
if (existingId) {
|
|
182
|
+
try {
|
|
183
|
+
await stripeClient.billingPortal.configurations.update(existingId, params)
|
|
184
|
+
return null
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const isMissing =
|
|
187
|
+
err &&
|
|
188
|
+
typeof err === 'object' &&
|
|
189
|
+
'code' in err &&
|
|
190
|
+
(err as { code?: string }).code === 'resource_missing'
|
|
191
|
+
if (!isMissing) throw err
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const created = await stripeClient.billingPortal.configurations.create(params)
|
|
196
|
+
return created.id
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (import.meta.main) {
|
|
200
|
+
setupStripePlans()
|
|
201
|
+
.then(() => process.exit(0))
|
|
202
|
+
.catch(err => {
|
|
203
|
+
console.error('Error setting up Stripe plans:', err)
|
|
204
|
+
process.exit(1)
|
|
205
|
+
})
|
|
206
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Sends one test email to check SMTP settings.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```bash
|
|
6
|
+
* bun run email:test you@example.com
|
|
7
|
+
* ```
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { render, toPlainText } from '@react-email/render'
|
|
11
|
+
import TestEmail from '~/emails/test'
|
|
12
|
+
import { sendEmail } from '~/lib/nodemailer'
|
|
13
|
+
|
|
14
|
+
async function sendTestMail(to: string) {
|
|
15
|
+
const html = await render(TestEmail({ name: to }), { pretty: true })
|
|
16
|
+
|
|
17
|
+
const result = await sendEmail({
|
|
18
|
+
to,
|
|
19
|
+
subject: 'Test Email',
|
|
20
|
+
text: toPlainText(html),
|
|
21
|
+
html
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
console.info('Test email sent', result)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (import.meta.main) {
|
|
28
|
+
const to = process.argv[2]
|
|
29
|
+
|
|
30
|
+
if (!to) {
|
|
31
|
+
console.error(
|
|
32
|
+
'Pass a recipient address: bun run email:test you@example.com'
|
|
33
|
+
)
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
sendTestMail(to)
|
|
38
|
+
.then(() => process.exit(0))
|
|
39
|
+
.catch(err => {
|
|
40
|
+
console.error('Error sending test mail:', err)
|
|
41
|
+
process.exit(1)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, describe, test } from 'bun:test'
|
|
2
|
+
import { createUser, getUsers, updateUser } from '~/app/(admin)/actions/users'
|
|
3
|
+
import {
|
|
4
|
+
asAnonymous,
|
|
5
|
+
asUser,
|
|
6
|
+
expectData,
|
|
7
|
+
expectServerError,
|
|
8
|
+
Fixtures
|
|
9
|
+
} from '../helpers'
|
|
10
|
+
|
|
11
|
+
describe('admin user management', () => {
|
|
12
|
+
const fx = new Fixtures()
|
|
13
|
+
afterEach(() => fx.cleanup())
|
|
14
|
+
|
|
15
|
+
test('signed-out and regular users cannot reach admin actions', async () => {
|
|
16
|
+
const user = await fx.user()
|
|
17
|
+
|
|
18
|
+
expectServerError(
|
|
19
|
+
await asAnonymous(() => getUsers({ page: 1, limit: 10 })),
|
|
20
|
+
'sign in'
|
|
21
|
+
)
|
|
22
|
+
expectServerError(
|
|
23
|
+
await asUser(user, () => getUsers({ page: 1, limit: 10 })),
|
|
24
|
+
'permission'
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('a superadmin cannot remove their own superadmin role', async () => {
|
|
29
|
+
const superadmin = await fx.platformUser('superadmin')
|
|
30
|
+
|
|
31
|
+
const result = await asUser(superadmin, () =>
|
|
32
|
+
updateUser({
|
|
33
|
+
id: superadmin.id,
|
|
34
|
+
name: superadmin.name,
|
|
35
|
+
email: superadmin.email,
|
|
36
|
+
role: 'admin'
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expectServerError(result, 'your own superadmin role')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('admins cannot manage users; superadmins can', async () => {
|
|
44
|
+
const admin = await fx.platformUser('admin')
|
|
45
|
+
const superadmin = await fx.platformUser('superadmin')
|
|
46
|
+
|
|
47
|
+
expectServerError(
|
|
48
|
+
await asUser(admin, () => getUsers({ page: 1, limit: 10 })),
|
|
49
|
+
'permission'
|
|
50
|
+
)
|
|
51
|
+
expectData(await asUser(superadmin, () => getUsers({ page: 1, limit: 10 })))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('creating a user with a taken email explains the conflict', async () => {
|
|
55
|
+
const superadmin = await fx.platformUser('superadmin')
|
|
56
|
+
const existing = await fx.user()
|
|
57
|
+
|
|
58
|
+
const result = await asUser(superadmin, () =>
|
|
59
|
+
createUser({ name: 'Duplicate', email: existing.email, role: 'user' })
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
expectServerError(result, 'already uses this email')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { sql } from 'drizzle-orm'
|
|
3
|
+
import { db } from '~/db'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Postgres does not index foreign key columns automatically. Without an index,
|
|
7
|
+
* every cascade or SET NULL from the parent side sequentially scans the child
|
|
8
|
+
* table while holding row locks, so deleting one organization can stall the
|
|
9
|
+
* platform.
|
|
10
|
+
*
|
|
11
|
+
* This finds every FK whose leading column is not the leading column of any
|
|
12
|
+
* index, which is exactly the shape that degrades.
|
|
13
|
+
*/
|
|
14
|
+
const UNINDEXED_FOREIGN_KEYS = sql`
|
|
15
|
+
SELECT
|
|
16
|
+
c.conrelid::regclass::text AS table_name,
|
|
17
|
+
a.attname::text AS column_name
|
|
18
|
+
FROM pg_constraint c
|
|
19
|
+
JOIN pg_attribute a
|
|
20
|
+
ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
|
21
|
+
WHERE c.contype = 'f'
|
|
22
|
+
AND NOT EXISTS (
|
|
23
|
+
SELECT 1
|
|
24
|
+
FROM pg_index i
|
|
25
|
+
WHERE i.indrelid = c.conrelid
|
|
26
|
+
AND i.indkey[0] = c.conkey[1]
|
|
27
|
+
)
|
|
28
|
+
ORDER BY 1, 2
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
/** postgres-js returns a row array; PGlite returns `{ rows }`. */
|
|
32
|
+
const toRows = <T>(result: unknown): T[] =>
|
|
33
|
+
Array.isArray(result)
|
|
34
|
+
? (result as T[])
|
|
35
|
+
: ((result as { rows: T[] }).rows ?? [])
|
|
36
|
+
|
|
37
|
+
describe('foreign key indexing', () => {
|
|
38
|
+
test('every foreign key column leads an index', async () => {
|
|
39
|
+
const rows = toRows<{ table_name: string; column_name: string }>(
|
|
40
|
+
await db.execute(UNINDEXED_FOREIGN_KEYS)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
expect(rows.map(r => `${r.table_name}.${r.column_name}`)).toEqual([])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('every foreign key declares an explicit delete rule', async () => {
|
|
47
|
+
const rows = toRows<{ table_name: string; constraint_name: string }>(
|
|
48
|
+
await db.execute(sql`
|
|
49
|
+
SELECT
|
|
50
|
+
c.conrelid::regclass::text AS table_name,
|
|
51
|
+
c.conname::text AS constraint_name
|
|
52
|
+
FROM pg_constraint c
|
|
53
|
+
WHERE c.contype = 'f' AND c.confdeltype = 'a'
|
|
54
|
+
ORDER BY 1, 2
|
|
55
|
+
`)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// confdeltype 'a' is NO ACTION, which is what an omitted onDelete leaves
|
|
59
|
+
// behind. Every FK should say cascade, set null, or restrict on purpose.
|
|
60
|
+
expect(rows.map(r => `${r.table_name}.${r.constraint_name}`)).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { PGlite } from '@electric-sql/pglite'
|
|
2
|
+
import { describe, expect, test } from 'bun:test'
|
|
3
|
+
import { pushSchema } from 'drizzle-kit/api'
|
|
4
|
+
import { drizzle } from 'drizzle-orm/pglite'
|
|
5
|
+
import * as relations from '~/db/relations'
|
|
6
|
+
import * as schema from '~/db/schema'
|
|
7
|
+
import { seedDatabase } from '~/scripts/seed'
|
|
8
|
+
import { setupDatabase } from '~/scripts/setup-db'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The seed writes straight through the schema, around Zod, so it exercises
|
|
12
|
+
* CHECK constraints, foreign keys, and triggers against realistic data.
|
|
13
|
+
*
|
|
14
|
+
* It runs on its own PGlite instance: seeded rows would otherwise leak into
|
|
15
|
+
* every other suite in the same worker and break their counts.
|
|
16
|
+
*/
|
|
17
|
+
describe('seed script', () => {
|
|
18
|
+
test('applies cleanly once, then skips', async () => {
|
|
19
|
+
// Same Emscripten proc_exit side effect as tests/setup/pglite-db.ts.
|
|
20
|
+
const exitCodeBeforePglite = process.exitCode
|
|
21
|
+
|
|
22
|
+
const client = new PGlite()
|
|
23
|
+
const db = drizzle({ client, schema: { ...schema, ...relations } })
|
|
24
|
+
|
|
25
|
+
const { apply, hasDataLoss } = await pushSchema(
|
|
26
|
+
schema,
|
|
27
|
+
db as unknown as Parameters<typeof pushSchema>[1]
|
|
28
|
+
)
|
|
29
|
+
expect(hasDataLoss).toBe(false)
|
|
30
|
+
await apply()
|
|
31
|
+
await setupDatabase(db as unknown as Parameters<typeof setupDatabase>[0])
|
|
32
|
+
|
|
33
|
+
const seed = () =>
|
|
34
|
+
seedDatabase(db as unknown as Parameters<typeof seedDatabase>[0])
|
|
35
|
+
|
|
36
|
+
expect((await seed()).skipped).toBe(false)
|
|
37
|
+
|
|
38
|
+
const [users, organizations, subscriptions] = await Promise.all([
|
|
39
|
+
db.select().from(schema.usersTable),
|
|
40
|
+
db.select().from(schema.organizationsTable),
|
|
41
|
+
db.select().from(schema.organizationSubscriptionsTable)
|
|
42
|
+
])
|
|
43
|
+
expect(users.map(user => user.role).sort()).toEqual(['superadmin', 'user'])
|
|
44
|
+
expect(organizations).toHaveLength(1)
|
|
45
|
+
expect(subscriptions).toHaveLength(1)
|
|
46
|
+
|
|
47
|
+
expect((await seed()).skipped).toBe(true)
|
|
48
|
+
expect(await db.select().from(schema.usersTable)).toHaveLength(2)
|
|
49
|
+
|
|
50
|
+
if (process.exitCode === 99) {
|
|
51
|
+
process.exitCode =
|
|
52
|
+
exitCodeBeforePglite !== undefined && exitCodeBeforePglite !== 99
|
|
53
|
+
? exitCodeBeforePglite
|
|
54
|
+
: 0
|
|
55
|
+
}
|
|
56
|
+
}, 120_000)
|
|
57
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { expect } from 'bun:test'
|
|
2
|
+
import { UNEXPECTED_ERROR_MESSAGE } from '~/lib/safe-action'
|
|
3
|
+
|
|
4
|
+
type ActionResult = {
|
|
5
|
+
data?: unknown
|
|
6
|
+
serverError?: string
|
|
7
|
+
validationErrors?: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MASKED = UNEXPECTED_ERROR_MESSAGE
|
|
11
|
+
|
|
12
|
+
export function expectData<T>(
|
|
13
|
+
result: ActionResult | undefined
|
|
14
|
+
): asserts result is { data: T } {
|
|
15
|
+
if (!result) throw new Error('Expected action result, got undefined')
|
|
16
|
+
if (result.serverError) {
|
|
17
|
+
throw new Error(`Expected success, got serverError: ${result.serverError}`)
|
|
18
|
+
}
|
|
19
|
+
if (result.validationErrors) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Expected success, got validationErrors: ${JSON.stringify(result.validationErrors)}`
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
if (result.data === undefined) {
|
|
25
|
+
throw new Error('Expected action data, got undefined')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Action returned null/undefined data (some actions swallow errors). */
|
|
30
|
+
export function expectNoData(result: ActionResult | undefined) {
|
|
31
|
+
expect(result?.data ?? null).toBeNull()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Action was rejected by its input schema, before any handler ran. */
|
|
35
|
+
export function expectValidationError(result: ActionResult | undefined) {
|
|
36
|
+
if (!result?.validationErrors) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Expected validationErrors, got: ${JSON.stringify(result ?? null)}`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
return result.validationErrors
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Action failed with a serverError.
|
|
46
|
+
* next-safe-action can mask thrown Error messages as a generic string.
|
|
47
|
+
* In that case, this helper only asserts that a failure occurred.
|
|
48
|
+
*/
|
|
49
|
+
export function expectServerError(
|
|
50
|
+
result: ActionResult | undefined,
|
|
51
|
+
messageIncludes?: string | RegExp
|
|
52
|
+
) {
|
|
53
|
+
if (!result?.serverError) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Expected serverError, got: ${JSON.stringify(result ?? null)}`
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
messageIncludes &&
|
|
60
|
+
result.serverError.toLowerCase() !== MASKED.toLowerCase()
|
|
61
|
+
) {
|
|
62
|
+
if (typeof messageIncludes === 'string') {
|
|
63
|
+
expect(result.serverError.toLowerCase()).toContain(
|
|
64
|
+
messageIncludes.toLowerCase()
|
|
65
|
+
)
|
|
66
|
+
} else {
|
|
67
|
+
expect(result.serverError).toMatch(messageIncludes)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result.serverError
|
|
71
|
+
}
|