create-ej-app 26.0.0 → 28.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -226
- package/cli.ts +79 -29
- package/package.json +2 -2
- package/template/api/.vscode/settings.json +1 -1
- package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
- package/template/api/CLAUDE.md +1 -0
- package/template/api/package.json +10 -10
- package/template/nextjs/.dockerignore +1 -0
- package/template/nextjs/AGENTS.md +260 -0
- package/template/nextjs/CLAUDE.md +1 -0
- package/template/nextjs/README.md +109 -0
- package/template/nextjs/bunfig.toml +3 -0
- package/template/nextjs/eslint.config.mjs +6 -5
- package/template/nextjs/gitignore +6 -1
- package/template/nextjs/next.config.ts +8 -1
- package/template/nextjs/package.json +47 -39
- package/template/nextjs/public/logo-dark.svg +4 -0
- package/template/nextjs/public/logo.svg +4 -0
- package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
- package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
- package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
- package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
- package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
- package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
- package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
- package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
- package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
- package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
- package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
- package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
- package/template/nextjs/src/app/error.tsx +37 -0
- package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/index.ts +60 -0
- package/template/nextjs/src/app/global-error.tsx +38 -0
- package/template/nextjs/src/app/globals.css +29 -0
- package/template/nextjs/src/app/layout.tsx +45 -18
- package/template/nextjs/src/app/not-found.tsx +25 -3
- package/template/nextjs/src/app/robots.ts +29 -0
- package/template/nextjs/src/app/sitemap.ts +21 -0
- package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
- package/template/nextjs/src/components/dashboard-header.tsx +70 -0
- package/template/nextjs/src/components/data-table/enum.ts +5 -0
- package/template/nextjs/src/components/data-table/features.ts +49 -0
- package/template/nextjs/src/components/data-table/index.tsx +961 -0
- package/template/nextjs/src/components/data-table/types.ts +76 -0
- package/template/nextjs/src/components/error.tsx +71 -0
- package/template/nextjs/src/components/loader.tsx +18 -2
- package/template/nextjs/src/components/logo.tsx +49 -0
- package/template/nextjs/src/components/navigation-search.tsx +140 -0
- package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
- package/template/nextjs/src/components/page-heading.tsx +38 -0
- package/template/nextjs/src/components/react-query-provider.tsx +41 -2
- package/template/nextjs/src/components/route-error.tsx +55 -0
- package/template/nextjs/src/components/screen-size.tsx +36 -0
- package/template/nextjs/src/components/site-footer.tsx +20 -4
- package/template/nextjs/src/components/theme-provider.tsx +48 -0
- package/template/nextjs/src/components/theme-toggle.tsx +58 -5
- package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
- package/template/nextjs/src/components/ui/avatar.tsx +112 -0
- package/template/nextjs/src/components/ui/badge.tsx +54 -0
- package/template/nextjs/src/components/ui/button.tsx +3 -1
- package/template/nextjs/src/components/ui/card.tsx +4 -4
- package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
- package/template/nextjs/src/components/ui/command.tsx +192 -0
- package/template/nextjs/src/components/ui/dialog.tsx +164 -0
- package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/nextjs/src/components/ui/input-group.tsx +156 -0
- package/template/nextjs/src/components/ui/select.tsx +195 -0
- package/template/nextjs/src/components/ui/sheet.tsx +143 -0
- package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
- package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
- package/template/nextjs/src/components/ui/table.tsx +116 -0
- package/template/nextjs/src/components/ui/textarea.tsx +18 -0
- package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
- package/template/nextjs/src/components/user-menu.tsx +206 -0
- package/template/nextjs/src/db/common.ts +10 -0
- package/template/nextjs/src/db/index.ts +16 -6
- package/template/nextjs/src/db/relations.ts +21 -0
- package/template/nextjs/src/db/schema.ts +85 -46
- package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
- package/template/nextjs/src/emails/magic-link.tsx +15 -13
- package/template/nextjs/src/emails/test.tsx +11 -9
- package/template/nextjs/src/emails/welcome.tsx +12 -9
- package/template/nextjs/src/env.ts +1 -1
- package/template/nextjs/src/hooks/use-mobile.ts +14 -12
- package/template/nextjs/src/instrumentation.ts +20 -2
- package/template/nextjs/src/lib/actions.ts +55 -0
- package/template/nextjs/src/lib/admin-navigation.ts +43 -0
- package/template/nextjs/src/lib/auth-client.ts +20 -9
- package/template/nextjs/src/lib/auth-permissions.ts +61 -0
- package/template/nextjs/src/lib/auth.ts +33 -3
- package/template/nextjs/src/lib/callback-url.ts +28 -0
- package/template/nextjs/src/lib/constants.ts +21 -0
- package/template/nextjs/src/lib/delay.ts +10 -0
- package/template/nextjs/src/lib/email-service.ts +21 -0
- package/template/nextjs/src/lib/errors.ts +74 -0
- package/template/nextjs/src/lib/format-currency.ts +22 -24
- package/template/nextjs/src/lib/format-date.ts +69 -6
- package/template/nextjs/src/lib/nodemailer.ts +27 -3
- package/template/nextjs/src/lib/otel.ts +92 -2
- package/template/nextjs/src/lib/safe-action-client.ts +60 -9
- package/template/nextjs/src/lib/safe-action.ts +152 -16
- package/template/nextjs/src/lib/search.ts +16 -0
- package/template/nextjs/src/lib/seo.ts +7 -0
- package/template/nextjs/src/lib/siteConfig.ts +17 -1
- package/template/nextjs/src/lib/toast-message.ts +87 -1
- package/template/nextjs/src/lib/validations.ts +30 -0
- package/template/nextjs/src/proxy.ts +48 -0
- package/template/nextjs/src/scripts/reset-db.ts +22 -0
- package/template/nextjs/src/scripts/seed.ts +59 -8
- package/template/nextjs/src/scripts/test-mail.ts +28 -20
- package/template/nextjs/tests/admin/users.test.ts +64 -0
- package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
- package/template/nextjs/tests/db/seed.test.ts +49 -0
- package/template/nextjs/tests/helpers/action.ts +71 -0
- package/template/nextjs/tests/helpers/fixtures.ts +78 -0
- package/template/nextjs/tests/helpers/index.ts +2 -0
- package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
- package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/nextjs/tests/lib/errors.test.ts +10 -0
- package/template/nextjs/tests/lib/search.test.ts +23 -0
- package/template/nextjs/tests/setup/pglite-db.ts +63 -0
- package/template/nextjs/tests/setup/preload.ts +95 -0
- package/template/nextjs/tsconfig.json +1 -0
- package/template/saas-starter/.dockerignore +1 -0
- package/template/saas-starter/.env.example +15 -15
- package/template/saas-starter/AGENTS.md +302 -0
- package/template/saas-starter/CLAUDE.md +1 -0
- package/template/saas-starter/README.md +124 -2
- package/template/saas-starter/bunfig.toml +3 -0
- package/template/saas-starter/eslint.config.mjs +6 -5
- package/template/saas-starter/gitignore +6 -1
- package/template/saas-starter/next.config.ts +19 -1
- package/template/saas-starter/package.json +50 -44
- package/template/saas-starter/public/logo-dark.svg +4 -0
- package/template/saas-starter/public/logo.svg +4 -0
- package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
- package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
- package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
- package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
- package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
- package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
- package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
- package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
- package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
- package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
- package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
- package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
- package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
- package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
- package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
- package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
- package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
- package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
- package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
- package/template/saas-starter/src/app/actions/uploads.ts +36 -0
- package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
- package/template/saas-starter/src/app/error.tsx +37 -0
- package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/index.ts +60 -0
- package/template/saas-starter/src/app/global-error.tsx +38 -0
- package/template/saas-starter/src/app/globals.css +29 -0
- package/template/saas-starter/src/app/layout.tsx +43 -19
- package/template/saas-starter/src/app/not-found.tsx +25 -3
- package/template/saas-starter/src/app/robots.ts +14 -10
- package/template/saas-starter/src/app/sitemap.ts +2 -1
- package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
- package/template/saas-starter/src/components/data-table/features.ts +49 -0
- package/template/saas-starter/src/components/data-table/index.tsx +65 -64
- package/template/saas-starter/src/components/data-table/types.ts +5 -4
- package/template/saas-starter/src/components/error.tsx +45 -0
- package/template/saas-starter/src/components/file-upload.tsx +65 -26
- package/template/saas-starter/src/components/loader.tsx +18 -2
- package/template/saas-starter/src/components/logo.tsx +49 -0
- package/template/saas-starter/src/components/navigation-search.tsx +2 -2
- package/template/saas-starter/src/components/page-heading.tsx +6 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
- package/template/saas-starter/src/components/route-error.tsx +55 -0
- package/template/saas-starter/src/components/screen-size.tsx +36 -0
- package/template/saas-starter/src/components/site-footer.tsx +20 -4
- package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
- package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
- package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
- package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
- package/template/saas-starter/src/components/theme-provider.tsx +48 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
- package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
- package/template/saas-starter/src/components/ui/badge.tsx +6 -1
- package/template/saas-starter/src/components/ui/button.tsx +2 -0
- package/template/saas-starter/src/components/ui/command.tsx +1 -1
- package/template/saas-starter/src/components/user-menu.tsx +147 -24
- package/template/saas-starter/src/db/common.ts +33 -1
- package/template/saas-starter/src/db/enums.ts +24 -0
- package/template/saas-starter/src/db/index.ts +14 -5
- package/template/saas-starter/src/db/relations.ts +40 -3
- package/template/saas-starter/src/db/schema.ts +186 -35
- package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
- package/template/saas-starter/src/emails/invitation.tsx +34 -21
- package/template/saas-starter/src/emails/magic-link.tsx +15 -13
- package/template/saas-starter/src/emails/test.tsx +11 -9
- package/template/saas-starter/src/emails/welcome.tsx +12 -9
- package/template/saas-starter/src/env.ts +1 -1
- package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
- package/template/saas-starter/src/instrumentation.ts +20 -2
- package/template/saas-starter/src/lib/actions.ts +34 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
- package/template/saas-starter/src/lib/app-navigation.ts +45 -41
- package/template/saas-starter/src/lib/auth-client.ts +8 -3
- package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
- package/template/saas-starter/src/lib/auth.ts +122 -21
- package/template/saas-starter/src/lib/billing.ts +59 -24
- package/template/saas-starter/src/lib/callback-url.ts +28 -0
- package/template/saas-starter/src/lib/constants.ts +78 -9
- package/template/saas-starter/src/lib/delay.ts +10 -0
- package/template/saas-starter/src/lib/email-service.ts +30 -1
- package/template/saas-starter/src/lib/errors.ts +74 -0
- package/template/saas-starter/src/lib/format-currency.ts +22 -24
- package/template/saas-starter/src/lib/format-date.ts +69 -6
- package/template/saas-starter/src/lib/image-url.ts +54 -0
- package/template/saas-starter/src/lib/nodemailer.ts +27 -3
- package/template/saas-starter/src/lib/organization-access.ts +56 -76
- package/template/saas-starter/src/lib/otel.ts +92 -2
- package/template/saas-starter/src/lib/plan-limits.ts +37 -0
- package/template/saas-starter/src/lib/plans.ts +40 -13
- package/template/saas-starter/src/lib/rbac.ts +44 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
- package/template/saas-starter/src/lib/safe-action.ts +137 -17
- package/template/saas-starter/src/lib/search.ts +16 -0
- package/template/saas-starter/src/lib/seo.ts +7 -0
- package/template/saas-starter/src/lib/siteConfig.ts +17 -1
- package/template/saas-starter/src/lib/storage.ts +176 -46
- package/template/saas-starter/src/lib/stripe.ts +4 -1
- package/template/saas-starter/src/lib/toast-message.ts +87 -1
- package/template/saas-starter/src/lib/validations.ts +85 -2
- package/template/saas-starter/src/proxy.ts +48 -0
- package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
- package/template/saas-starter/src/scripts/seed.ts +82 -8
- package/template/saas-starter/src/scripts/setup-db.ts +72 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
- package/template/saas-starter/src/scripts/test-mail.ts +28 -20
- package/template/saas-starter/tests/admin/users.test.ts +64 -0
- package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
- package/template/saas-starter/tests/db/seed.test.ts +57 -0
- package/template/saas-starter/tests/helpers/action.ts +71 -0
- package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
- package/template/saas-starter/tests/helpers/index.ts +2 -0
- package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
- package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/saas-starter/tests/lib/errors.test.ts +10 -0
- package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
- package/template/saas-starter/tests/lib/search.test.ts +23 -0
- package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
- package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
- package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
- package/template/saas-starter/tests/setup/preload.ts +108 -0
- package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
- package/template/saas-starter/tsconfig.json +1 -0
- package/cli.js +0 -147
- package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
- package/template/nextjs/src/app/(app)/layout.tsx +0 -20
- package/template/nextjs/terraform/artifact-registry.tf +0 -5
- package/template/nextjs/terraform/backend-service.tf +0 -13
- package/template/nextjs/terraform/cleanup.sh +0 -9
- package/template/nextjs/terraform/cloudbuild.tf +0 -70
- package/template/nextjs/terraform/cloudrun.tf +0 -68
- package/template/nextjs/terraform/database.tf +0 -60
- package/template/nextjs/terraform/forwarding-rule.tf +0 -43
- package/template/nextjs/terraform/gitignore +0 -4
- package/template/nextjs/terraform/ip-address.tf +0 -11
- package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
- package/template/nextjs/terraform/network.tf +0 -21
- package/template/nextjs/terraform/pre.sh +0 -3
- package/template/nextjs/terraform/providers.tf +0 -18
- package/template/nextjs/terraform/secret.txt +0 -11
- package/template/nextjs/terraform/setup.sh +0 -25
- package/template/nextjs/terraform/ssl-certs.tf +0 -32
- package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
- package/template/nextjs/terraform/target-https-proxy.tf +0 -14
- package/template/nextjs/terraform/terraform.tfvars +0 -4
- package/template/nextjs/terraform/url-map.tf +0 -23
- package/template/nextjs/terraform/variables.tf +0 -21
- package/template/saas-starter/next-env.d.ts +0 -6
- package/template/saas-starter/src/actions/uploads.ts +0 -28
- package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
- package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
- package/template/saas-starter/src/app/proxy.ts +0 -28
- package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
- package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
|
@@ -1,12 +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
|
+
*/
|
|
1
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'
|
|
2
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Phone number: 7 to 15 digits once separators are removed (E.164 maximum).
|
|
20
|
+
* Allowed characters are digits, `+`, `-`, `()`, `.`, and spaces.
|
|
21
|
+
*/
|
|
3
22
|
export const phoneValidation = z
|
|
4
23
|
.string()
|
|
5
24
|
.trim()
|
|
6
|
-
.min(10)
|
|
7
25
|
.max(20)
|
|
8
|
-
.regex(/^[\d+\-().\s]
|
|
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.')
|
|
9
31
|
|
|
32
|
+
/** Email address, trimmed and lowercased. */
|
|
10
33
|
export const emailValidation = z.email().trim().toLowerCase()
|
|
11
34
|
|
|
35
|
+
/** Required short string, 1 to 255 characters after trimming. */
|
|
12
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
|
+
}
|
|
@@ -1,11 +1,85 @@
|
|
|
1
|
-
|
|
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
|
+
}
|
|
2
74
|
|
|
3
75
|
if (import.meta.main) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
})
|
|
11
85
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
*/
|
|
1
8
|
import { writeFile } from 'fs/promises'
|
|
2
9
|
import type Stripe from 'stripe'
|
|
3
10
|
import { env } from '~/env'
|
|
@@ -190,11 +197,10 @@ const setupPortalConfiguration = async (paidPlans: PaidPlanPrices[]) => {
|
|
|
190
197
|
}
|
|
191
198
|
|
|
192
199
|
if (import.meta.main) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
+
setupStripePlans()
|
|
201
|
+
.then(() => process.exit(0))
|
|
202
|
+
.catch(err => {
|
|
203
|
+
console.error('Error setting up Stripe plans:', err)
|
|
204
|
+
process.exit(1)
|
|
205
|
+
})
|
|
200
206
|
}
|
|
@@ -1,35 +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
|
+
|
|
1
10
|
import { render, toPlainText } from '@react-email/render'
|
|
2
11
|
import TestEmail from '~/emails/test'
|
|
3
12
|
import { sendEmail } from '~/lib/nodemailer'
|
|
4
13
|
|
|
5
|
-
async function sendTestMail() {
|
|
6
|
-
const html = await render(
|
|
7
|
-
TestEmail({
|
|
8
|
-
name: 'John Doe'
|
|
9
|
-
}),
|
|
10
|
-
{
|
|
11
|
-
pretty: true
|
|
12
|
-
}
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
const text = toPlainText(html)
|
|
14
|
+
async function sendTestMail(to: string) {
|
|
15
|
+
const html = await render(TestEmail({ name: to }), { pretty: true })
|
|
16
16
|
|
|
17
17
|
const result = await sendEmail({
|
|
18
|
-
to
|
|
18
|
+
to,
|
|
19
19
|
subject: 'Test Email',
|
|
20
|
-
text,
|
|
20
|
+
text: toPlainText(html),
|
|
21
21
|
html
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
-
console.info('Test email sent
|
|
24
|
+
console.info('Test email sent', result)
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
if (import.meta.main) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
console.error(
|
|
32
|
-
|
|
33
|
-
|
|
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)
|
|
34
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
|
+
})
|
|
35
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
|
+
}
|