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
|
@@ -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 user 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,49 @@
|
|
|
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
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The seed writes straight through the schema, around Zod, so it exercises
|
|
11
|
+
* constraints and foreign keys against realistic data.
|
|
12
|
+
*
|
|
13
|
+
* It runs on its own PGlite instance: seeded rows would otherwise leak into
|
|
14
|
+
* every other suite in the same worker and break their counts.
|
|
15
|
+
*/
|
|
16
|
+
describe('seed script', () => {
|
|
17
|
+
test('applies cleanly once, then skips', async () => {
|
|
18
|
+
// Same Emscripten proc_exit side effect as tests/setup/pglite-db.ts.
|
|
19
|
+
const exitCodeBeforePglite = process.exitCode
|
|
20
|
+
|
|
21
|
+
const client = new PGlite()
|
|
22
|
+
const db = drizzle({ client, schema: { ...schema, ...relations } })
|
|
23
|
+
|
|
24
|
+
const { apply, hasDataLoss } = await pushSchema(
|
|
25
|
+
schema,
|
|
26
|
+
db as unknown as Parameters<typeof pushSchema>[1]
|
|
27
|
+
)
|
|
28
|
+
expect(hasDataLoss).toBe(false)
|
|
29
|
+
await apply()
|
|
30
|
+
|
|
31
|
+
const seed = () =>
|
|
32
|
+
seedDatabase(db as unknown as Parameters<typeof seedDatabase>[0])
|
|
33
|
+
|
|
34
|
+
expect((await seed()).skipped).toBe(false)
|
|
35
|
+
|
|
36
|
+
const users = await db.select().from(schema.usersTable)
|
|
37
|
+
expect(users.map(user => user.role).sort()).toEqual(['superadmin', 'user'])
|
|
38
|
+
|
|
39
|
+
expect((await seed()).skipped).toBe(true)
|
|
40
|
+
expect(await db.select().from(schema.usersTable)).toHaveLength(2)
|
|
41
|
+
|
|
42
|
+
if (process.exitCode === 99) {
|
|
43
|
+
process.exitCode =
|
|
44
|
+
exitCodeBeforePglite !== undefined && exitCodeBeforePglite !== 99
|
|
45
|
+
? exitCodeBeforePglite
|
|
46
|
+
: 0
|
|
47
|
+
}
|
|
48
|
+
}, 120_000)
|
|
49
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throwaway DB fixtures and auth helpers for Bun tests.
|
|
3
|
+
*
|
|
4
|
+
* const fx = new Fixtures()
|
|
5
|
+
* afterEach(() => fx.cleanup())
|
|
6
|
+
* const admin = await fx.platformUser('superadmin')
|
|
7
|
+
* await asUser(admin, () => someAction(...))
|
|
8
|
+
*/
|
|
9
|
+
import { createId } from '@paralleldrive/cuid2'
|
|
10
|
+
import { inArray } from 'drizzle-orm'
|
|
11
|
+
import { db } from '~/db'
|
|
12
|
+
import { usersTable } from '~/db/schema'
|
|
13
|
+
import { runWithAuthSession } from '../setup/preload'
|
|
14
|
+
|
|
15
|
+
type User = typeof usersTable.$inferSelect
|
|
16
|
+
|
|
17
|
+
const uid = () =>
|
|
18
|
+
`${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
|
19
|
+
|
|
20
|
+
export class Fixtures {
|
|
21
|
+
private userIds: string[] = []
|
|
22
|
+
|
|
23
|
+
async user(
|
|
24
|
+
overrides: { name?: string; email?: string; role?: string | null } = {}
|
|
25
|
+
): Promise<User> {
|
|
26
|
+
const s = uid()
|
|
27
|
+
const [row] = await db
|
|
28
|
+
.insert(usersTable)
|
|
29
|
+
.values({
|
|
30
|
+
id: `user_${createId()}`,
|
|
31
|
+
name: overrides.name ?? `Test User ${s}`,
|
|
32
|
+
email: overrides.email ?? `test+${s}@example.com`,
|
|
33
|
+
emailVerified: true,
|
|
34
|
+
role: overrides.role ?? 'user'
|
|
35
|
+
})
|
|
36
|
+
.returning()
|
|
37
|
+
if (!row) throw new Error('Failed to create test user')
|
|
38
|
+
this.userIds.push(row.id)
|
|
39
|
+
return row
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Platform user for admin-panel tests. */
|
|
43
|
+
platformUser(role: 'admin' | 'superadmin') {
|
|
44
|
+
return this.user({ name: `Platform ${role}`, role })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async cleanup() {
|
|
48
|
+
if (this.userIds.length) {
|
|
49
|
+
await db.delete(usersTable).where(inArray(usersTable.id, this.userIds))
|
|
50
|
+
this.userIds = []
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Runs `fn` with `getAuthSession()` returning this user. */
|
|
56
|
+
export function asUser<T>(user: User, fn: () => Promise<T>): Promise<T> {
|
|
57
|
+
const isSuperAdmin = user.role === 'superadmin'
|
|
58
|
+
const isAdmin = user.role === 'admin' || isSuperAdmin
|
|
59
|
+
return runWithAuthSession(
|
|
60
|
+
{
|
|
61
|
+
user: {
|
|
62
|
+
id: user.id,
|
|
63
|
+
email: user.email,
|
|
64
|
+
name: user.name,
|
|
65
|
+
role: user.role
|
|
66
|
+
},
|
|
67
|
+
session: { id: `test_session_${user.id}`, userId: user.id },
|
|
68
|
+
isAdmin,
|
|
69
|
+
isSuperAdmin
|
|
70
|
+
},
|
|
71
|
+
fn
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Runs `fn` signed out. */
|
|
76
|
+
export function asAnonymous<T>(fn: () => Promise<T>): Promise<T> {
|
|
77
|
+
return runWithAuthSession(null, fn)
|
|
78
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { sanitizeCallbackUrl } from '~/lib/callback-url'
|
|
3
|
+
|
|
4
|
+
// Everything sanitizeCallbackUrl rejects must be something a browser would
|
|
5
|
+
// resolve off-origin, so each case is checked against URL resolution too.
|
|
6
|
+
const APP_ORIGIN = 'https://app.example.com'
|
|
7
|
+
|
|
8
|
+
const resolvesOffOrigin = (value: string) =>
|
|
9
|
+
new URL(value, APP_ORIGIN).origin !== APP_ORIGIN
|
|
10
|
+
|
|
11
|
+
describe('sanitizeCallbackUrl', () => {
|
|
12
|
+
test('keeps same-origin paths, including query and hash', () => {
|
|
13
|
+
expect(sanitizeCallbackUrl('/app')).toBe('/app')
|
|
14
|
+
expect(sanitizeCallbackUrl('/accept-invitation/inv_abc123')).toBe(
|
|
15
|
+
'/accept-invitation/inv_abc123'
|
|
16
|
+
)
|
|
17
|
+
expect(sanitizeCallbackUrl('/app/orders?status=open#top')).toBe(
|
|
18
|
+
'/app/orders?status=open#top'
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('rejects values that are not a path', () => {
|
|
23
|
+
expect(sanitizeCallbackUrl(undefined)).toBeNull()
|
|
24
|
+
expect(sanitizeCallbackUrl(['/app', '/admin'])).toBeNull()
|
|
25
|
+
expect(sanitizeCallbackUrl('app')).toBeNull()
|
|
26
|
+
expect(sanitizeCallbackUrl('https://evil.com')).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('rejects the backslash and double-slash escapes out of the origin', () => {
|
|
30
|
+
for (const value of ['//evil.com', '/\\evil.com', '/\\\\evil.com']) {
|
|
31
|
+
expect(resolvesOffOrigin(value)).toBe(true)
|
|
32
|
+
expect(sanitizeCallbackUrl(value)).toBeNull()
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('rejects tabs and newlines, which URL parsing strips before resolving', () => {
|
|
37
|
+
for (const value of ['/\t/evil.com', '/\n/evil.com', '/\r/evil.com']) {
|
|
38
|
+
expect(resolvesOffOrigin(value)).toBe(true)
|
|
39
|
+
expect(sanitizeCallbackUrl(value)).toBeNull()
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('leaves lookalike hosts on the app origin instead of rejecting them', () => {
|
|
44
|
+
// These stay same-origin, so they are safe to honour.
|
|
45
|
+
expect(sanitizeCallbackUrl('/@evil.com')).toBe('/@evil.com')
|
|
46
|
+
expect(sanitizeCallbackUrl('/%5Cevil.com')).toBe('/%5Cevil.com')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard: `'use client'` modules must not import server runtime, secrets, or
|
|
3
|
+
* the database directly. Transitive imports leak too, but this test only reads
|
|
4
|
+
* each client file's own imports, so review what a new import pulls in.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test'
|
|
7
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
|
|
10
|
+
const SRC = path.resolve(import.meta.dir, '../../src')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Import paths that must never appear in a `'use client'` module. Add every
|
|
14
|
+
* new server-only lib here.
|
|
15
|
+
*/
|
|
16
|
+
const FORBIDDEN_IMPORT_RE =
|
|
17
|
+
/from\s+['"](~\/db(?:\/(?:schema|enums|relations|common|index))?|~\/env|~\/lib\/(?:auth|safe-action|email-service|nodemailer)|server-only|drizzle-orm(?:\/[^'"]*)?)['"]/
|
|
18
|
+
|
|
19
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
20
|
+
for (const name of readdirSync(dir)) {
|
|
21
|
+
if (name === 'node_modules' || name.startsWith('.')) continue
|
|
22
|
+
const full = path.join(dir, name)
|
|
23
|
+
if (statSync(full).isDirectory()) walk(full, out)
|
|
24
|
+
else if (/\.(ts|tsx)$/.test(name)) out.push(full)
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('client / server boundary', () => {
|
|
30
|
+
test('no use-client file imports server runtime or db modules', () => {
|
|
31
|
+
const violations: string[] = []
|
|
32
|
+
|
|
33
|
+
for (const file of walk(SRC)) {
|
|
34
|
+
const source = readFileSync(file, 'utf8')
|
|
35
|
+
if (
|
|
36
|
+
!source.includes("'use client'") &&
|
|
37
|
+
!source.includes('"use client"')
|
|
38
|
+
) {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const [index, line] of source.split('\n').entries()) {
|
|
43
|
+
// Type-only imports are erased, but they are banned too so a later
|
|
44
|
+
// value import is harder to slip in.
|
|
45
|
+
if (!FORBIDDEN_IMPORT_RE.test(line)) continue
|
|
46
|
+
// Explicit client-safe siblings
|
|
47
|
+
if (line.includes('auth-client')) continue
|
|
48
|
+
if (line.includes('safe-action-client')) continue
|
|
49
|
+
|
|
50
|
+
const rel = path.relative(SRC, file)
|
|
51
|
+
violations.push(`${rel}:${index + 1}: ${line.trim()}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
expect(violations).toEqual([])
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { isUniqueViolation } from '~/lib/errors'
|
|
3
|
+
|
|
4
|
+
describe('postgres error codes', () => {
|
|
5
|
+
test('an error whose cause points back at itself is not a match and does not hang', () => {
|
|
6
|
+
const error: { message: string; cause?: unknown } = { message: 'loop' }
|
|
7
|
+
error.cause = error
|
|
8
|
+
expect(isUniqueViolation(error)).toBe(false)
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { escapeLikePattern, likeContains } from '~/lib/search'
|
|
3
|
+
|
|
4
|
+
describe('escapeLikePattern', () => {
|
|
5
|
+
test('escapes LIKE wildcards and the escape character', () => {
|
|
6
|
+
expect(escapeLikePattern('%')).toBe('\\%')
|
|
7
|
+
expect(escapeLikePattern('_')).toBe('\\_')
|
|
8
|
+
expect(escapeLikePattern('\\')).toBe('\\\\')
|
|
9
|
+
expect(escapeLikePattern('100%_off\\sale')).toBe('100\\%\\_off\\\\sale')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('leaves ordinary search text unchanged', () => {
|
|
13
|
+
expect(escapeLikePattern('Quarterly report')).toBe('Quarterly report')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('likeContains', () => {
|
|
18
|
+
test('wraps an escaped pattern for contains matching', () => {
|
|
19
|
+
expect(likeContains('report')).toBe('%report%')
|
|
20
|
+
expect(likeContains('%')).toBe('%\\%%')
|
|
21
|
+
expect(likeContains('_')).toBe('%\\_%')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory Postgres for Bun tests via Electric PGlite (WASM).
|
|
3
|
+
*
|
|
4
|
+
* One instance per Bun worker process:
|
|
5
|
+
* 1. Start PGlite
|
|
6
|
+
* 2. pushSchema from Drizzle table definitions
|
|
7
|
+
*/
|
|
8
|
+
import { PGlite } from '@electric-sql/pglite'
|
|
9
|
+
import { pushSchema } from 'drizzle-kit/api'
|
|
10
|
+
import { drizzle, type PgliteDatabase } from 'drizzle-orm/pglite'
|
|
11
|
+
import * as relations from '~/db/relations'
|
|
12
|
+
import * as schema from '~/db/schema'
|
|
13
|
+
|
|
14
|
+
const fullSchema = { ...schema, ...relations }
|
|
15
|
+
|
|
16
|
+
type TestDatabase = PgliteDatabase<typeof fullSchema>
|
|
17
|
+
|
|
18
|
+
let initPromise: Promise<TestDatabase> | null = null
|
|
19
|
+
|
|
20
|
+
async function bootstrap(): Promise<TestDatabase> {
|
|
21
|
+
// PGlite's Emscripten runtime sets process.exitCode to 99 via proc_exit on
|
|
22
|
+
// the first WASM query. bun test then exits with that code even when every
|
|
23
|
+
// assertion passed. Snapshot first, restore only the spurious 99, and leave
|
|
24
|
+
// any real non-zero code alone.
|
|
25
|
+
const exitCodeBeforePglite = process.exitCode
|
|
26
|
+
|
|
27
|
+
const client = new PGlite()
|
|
28
|
+
const db = drizzle({ client, schema: fullSchema })
|
|
29
|
+
|
|
30
|
+
const { apply, warnings, hasDataLoss } = await pushSchema(
|
|
31
|
+
schema,
|
|
32
|
+
db as unknown as Parameters<typeof pushSchema>[1]
|
|
33
|
+
)
|
|
34
|
+
if (warnings.length) {
|
|
35
|
+
console.warn('[pglite] schema push warnings:', warnings)
|
|
36
|
+
}
|
|
37
|
+
if (hasDataLoss) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'[pglite] schema push reported data loss on a fresh database. Aborting.'
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
await apply()
|
|
43
|
+
|
|
44
|
+
if (process.exitCode === 99) {
|
|
45
|
+
process.exitCode =
|
|
46
|
+
exitCodeBeforePglite !== undefined && exitCodeBeforePglite !== 99
|
|
47
|
+
? exitCodeBeforePglite
|
|
48
|
+
: 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return db
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Idempotent: first call boots PGlite and the schema; later calls return the same db.
|
|
56
|
+
* Safe to await from preload (once per worker).
|
|
57
|
+
*/
|
|
58
|
+
export function initPgliteDatabase(): Promise<TestDatabase> {
|
|
59
|
+
if (!initPromise) {
|
|
60
|
+
initPromise = bootstrap()
|
|
61
|
+
}
|
|
62
|
+
return initPromise
|
|
63
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun test preload (runs before every test file and worker).
|
|
3
|
+
*
|
|
4
|
+
* - In-memory PGlite Postgres (WASM) through a `~/db` mock
|
|
5
|
+
* - `react.cache` becomes identity
|
|
6
|
+
* - `getAuthSession` reads an AsyncLocalStorage (parallel-safe)
|
|
7
|
+
*
|
|
8
|
+
* Set `TEST_DATABASE=postgres` to use the real `DATABASE_URL` instead.
|
|
9
|
+
*/
|
|
10
|
+
import { mock } from 'bun:test'
|
|
11
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
12
|
+
import { createRequire } from 'node:module'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
// Bun does not set the `react-server` export condition, so the real
|
|
16
|
+
// `server-only` package throws. Next.js client builds still fail on it.
|
|
17
|
+
mock.module('server-only', () => ({}))
|
|
18
|
+
|
|
19
|
+
// `start()` needs the Workflow runtime. Tests only need fire-and-forget
|
|
20
|
+
// side effects such as emails to no-op.
|
|
21
|
+
mock.module('workflow/api', () => ({
|
|
22
|
+
start: async () => ({ runId: 'test-workflow-run' })
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Database: PGlite (default) or real Postgres
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
process.env.TEST_DATABASE ??= 'pglite'
|
|
30
|
+
|
|
31
|
+
if (process.env.TEST_DATABASE === 'pglite') {
|
|
32
|
+
// ~/env requires a URL even though PGlite never connects to it.
|
|
33
|
+
process.env.DATABASE_URL ??= 'postgres://pglite.local/test'
|
|
34
|
+
|
|
35
|
+
const { initPgliteDatabase } = await import('./pglite-db')
|
|
36
|
+
const dbModule = { db: await initPgliteDatabase() }
|
|
37
|
+
|
|
38
|
+
mock.module('~/db', () => dbModule)
|
|
39
|
+
mock.module(
|
|
40
|
+
path.resolve(import.meta.dir, '../../src/db/index.ts'),
|
|
41
|
+
() => dbModule
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Auth and request headers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
type MockAuthSession = {
|
|
50
|
+
user: { id: string; email: string; name: string; role?: string | null }
|
|
51
|
+
session: { id: string; userId: string; impersonatedBy?: string | null }
|
|
52
|
+
isAdmin: boolean
|
|
53
|
+
isSuperAdmin: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const authAls = new AsyncLocalStorage<MockAuthSession | null>()
|
|
57
|
+
|
|
58
|
+
/** Runs `fn` with `getAuthSession()` returning `session`. */
|
|
59
|
+
export function runWithAuthSession<T>(
|
|
60
|
+
session: MockAuthSession | null,
|
|
61
|
+
fn: () => Promise<T>
|
|
62
|
+
): Promise<T> {
|
|
63
|
+
return authAls.run(session, fn)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const require = createRequire(import.meta.url)
|
|
67
|
+
const React = require('react') as typeof import('react')
|
|
68
|
+
|
|
69
|
+
mock.module('react', () => ({
|
|
70
|
+
...React,
|
|
71
|
+
default: React,
|
|
72
|
+
cache: <T extends (...args: never[]) => unknown>(fn: T) => fn
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
const authModuleMock = {
|
|
76
|
+
getAuthSession: async () => authAls.getStore() ?? null,
|
|
77
|
+
auth: null as unknown
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
mock.module('~/lib/auth', () => authModuleMock)
|
|
81
|
+
mock.module(
|
|
82
|
+
path.resolve(import.meta.dir, '../../src/lib/auth.ts'),
|
|
83
|
+
() => authModuleMock
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
mock.module('next/headers', () => ({
|
|
87
|
+
headers: async () => new Headers(),
|
|
88
|
+
cookies: async () => ({
|
|
89
|
+
get: () => undefined,
|
|
90
|
+
getAll: () => [],
|
|
91
|
+
has: () => false,
|
|
92
|
+
set: () => {},
|
|
93
|
+
delete: () => {}
|
|
94
|
+
})
|
|
95
|
+
}))
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
APP_ENV="
|
|
1
|
+
APP_ENV="development"
|
|
2
2
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
|
3
3
|
BETTER_AUTH_URL="http://localhost:3000"
|
|
4
|
-
BETTER_AUTH_SECRET="
|
|
5
|
-
BETTER_AUTH_GITHUB_ID="
|
|
6
|
-
BETTER_AUTH_GITHUB_SECRET="
|
|
7
|
-
BETTER_AUTH_GOOGLE_ID="
|
|
8
|
-
BETTER_AUTH_GOOGLE_SECRET="
|
|
9
|
-
EMAIL_SERVER_USER="
|
|
10
|
-
EMAIL_SERVER_PASSWORD="
|
|
4
|
+
BETTER_AUTH_SECRET="your_better_auth_secret_here"
|
|
5
|
+
BETTER_AUTH_GITHUB_ID="your_github_client_id_here"
|
|
6
|
+
BETTER_AUTH_GITHUB_SECRET="your_github_client_secret_here"
|
|
7
|
+
BETTER_AUTH_GOOGLE_ID="your_google_client_id_here"
|
|
8
|
+
BETTER_AUTH_GOOGLE_SECRET="your_google_client_secret_here"
|
|
9
|
+
EMAIL_SERVER_USER="your_smtp_username_here"
|
|
10
|
+
EMAIL_SERVER_PASSWORD="your_smtp_password_here"
|
|
11
11
|
EMAIL_SERVER_HOST="smtp.example.com"
|
|
12
12
|
EMAIL_SERVER_PORT="465"
|
|
13
13
|
EMAIL_FROM="no-reply@example.com"
|
|
14
|
-
STRIPE_SECRET_KEY="
|
|
15
|
-
STRIPE_WEBHOOK_SECRET="
|
|
16
|
-
STRIPE_PRICE_PRO_MONTHLY="
|
|
17
|
-
STRIPE_PRICE_PRO_ANNUAL="
|
|
18
|
-
STRIPE_PRICE_ULTIMATE_MONTHLY="
|
|
19
|
-
STRIPE_PRICE_ULTIMATE_ANNUAL="
|
|
20
|
-
STRIPE_PORTAL_CONFIGURATION_ID="
|
|
14
|
+
STRIPE_SECRET_KEY="your_stripe_secret_key_here"
|
|
15
|
+
STRIPE_WEBHOOK_SECRET="your_stripe_webhook_secret_here"
|
|
16
|
+
STRIPE_PRICE_PRO_MONTHLY="your_pro_monthly_price_id_here"
|
|
17
|
+
STRIPE_PRICE_PRO_ANNUAL="your_pro_annual_price_id_here"
|
|
18
|
+
STRIPE_PRICE_ULTIMATE_MONTHLY="your_ultimate_monthly_price_id_here"
|
|
19
|
+
STRIPE_PRICE_ULTIMATE_ANNUAL="your_ultimate_annual_price_id_here"
|
|
20
|
+
STRIPE_PORTAL_CONFIGURATION_ID="your_portal_configuration_id_here"
|
|
21
21
|
R2_ACCOUNT_ID=""
|
|
22
22
|
R2_ACCESS_KEY_ID=""
|
|
23
23
|
R2_SECRET_ACCESS_KEY=""
|