create-ej-app 25.0.0 → 27.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -226
- package/cli.ts +83 -28
- package/package.json +2 -2
- package/template/api/.vscode/settings.json +1 -1
- package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
- package/template/api/CLAUDE.md +1 -0
- package/template/api/package.json +10 -10
- package/template/nextjs/.dockerignore +1 -0
- package/template/nextjs/AGENTS.md +260 -0
- package/template/nextjs/CLAUDE.md +1 -0
- package/template/nextjs/README.md +109 -0
- package/template/nextjs/bunfig.toml +3 -0
- package/template/nextjs/eslint.config.mjs +6 -5
- package/template/nextjs/gitignore +6 -0
- package/template/nextjs/next.config.ts +8 -1
- package/template/nextjs/package.json +47 -40
- package/template/nextjs/public/logo-dark.svg +4 -0
- package/template/nextjs/public/logo.svg +4 -0
- package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
- package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
- package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
- package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
- package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
- package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
- package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
- package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
- package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
- package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
- package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
- package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
- package/template/nextjs/src/app/error.tsx +37 -0
- package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/index.ts +60 -0
- package/template/nextjs/src/app/global-error.tsx +38 -0
- package/template/nextjs/src/app/globals.css +29 -0
- package/template/nextjs/src/app/layout.tsx +45 -18
- package/template/nextjs/src/app/not-found.tsx +25 -3
- package/template/nextjs/src/app/robots.ts +29 -0
- package/template/nextjs/src/app/sitemap.ts +21 -0
- package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
- package/template/nextjs/src/components/dashboard-header.tsx +70 -0
- package/template/nextjs/src/components/data-table/enum.ts +5 -0
- package/template/nextjs/src/components/data-table/features.ts +49 -0
- package/template/nextjs/src/components/data-table/index.tsx +961 -0
- package/template/nextjs/src/components/data-table/types.ts +76 -0
- package/template/nextjs/src/components/error.tsx +71 -0
- package/template/nextjs/src/components/loader.tsx +18 -2
- package/template/nextjs/src/components/logo.tsx +49 -0
- package/template/nextjs/src/components/navigation-search.tsx +140 -0
- package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
- package/template/nextjs/src/components/page-heading.tsx +38 -0
- package/template/nextjs/src/components/react-query-provider.tsx +41 -2
- package/template/nextjs/src/components/route-error.tsx +55 -0
- package/template/nextjs/src/components/screen-size.tsx +36 -0
- package/template/nextjs/src/components/site-footer.tsx +20 -4
- package/template/nextjs/src/components/theme-provider.tsx +48 -0
- package/template/nextjs/src/components/theme-toggle.tsx +58 -5
- package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
- package/template/nextjs/src/components/ui/avatar.tsx +112 -0
- package/template/nextjs/src/components/ui/badge.tsx +54 -0
- package/template/nextjs/src/components/ui/button.tsx +3 -1
- package/template/nextjs/src/components/ui/card.tsx +4 -4
- package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
- package/template/nextjs/src/components/ui/command.tsx +192 -0
- package/template/nextjs/src/components/ui/dialog.tsx +164 -0
- package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/nextjs/src/components/ui/input-group.tsx +156 -0
- package/template/nextjs/src/components/ui/select.tsx +195 -0
- package/template/nextjs/src/components/ui/sheet.tsx +143 -0
- package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
- package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
- package/template/nextjs/src/components/ui/table.tsx +116 -0
- package/template/nextjs/src/components/ui/textarea.tsx +18 -0
- package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
- package/template/nextjs/src/components/user-menu.tsx +206 -0
- package/template/nextjs/src/db/common.ts +10 -0
- package/template/nextjs/src/db/index.ts +16 -6
- package/template/nextjs/src/db/relations.ts +21 -0
- package/template/nextjs/src/db/schema.ts +85 -46
- package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
- package/template/nextjs/src/emails/magic-link.tsx +15 -13
- package/template/nextjs/src/emails/test.tsx +11 -9
- package/template/nextjs/src/emails/welcome.tsx +12 -9
- package/template/nextjs/src/env.ts +1 -1
- package/template/nextjs/src/hooks/use-mobile.ts +14 -12
- package/template/nextjs/src/instrumentation.ts +20 -2
- package/template/nextjs/src/lib/actions.ts +55 -0
- package/template/nextjs/src/lib/admin-navigation.ts +43 -0
- package/template/nextjs/src/lib/auth-client.ts +20 -9
- package/template/nextjs/src/lib/auth-permissions.ts +61 -0
- package/template/nextjs/src/lib/auth.ts +33 -3
- package/template/nextjs/src/lib/callback-url.ts +28 -0
- package/template/nextjs/src/lib/constants.ts +21 -0
- package/template/nextjs/src/lib/delay.ts +10 -0
- package/template/nextjs/src/lib/email-service.ts +21 -0
- package/template/nextjs/src/lib/errors.ts +74 -0
- package/template/nextjs/src/lib/format-currency.ts +22 -24
- package/template/nextjs/src/lib/format-date.ts +69 -6
- package/template/nextjs/src/lib/nodemailer.ts +27 -3
- package/template/nextjs/src/lib/otel.ts +92 -2
- package/template/nextjs/src/lib/safe-action-client.ts +60 -9
- package/template/nextjs/src/lib/safe-action.ts +152 -16
- package/template/nextjs/src/lib/search.ts +16 -0
- package/template/nextjs/src/lib/seo.ts +7 -0
- package/template/nextjs/src/lib/siteConfig.ts +17 -1
- package/template/nextjs/src/lib/toast-message.ts +87 -1
- package/template/nextjs/src/lib/validations.ts +30 -0
- package/template/nextjs/src/proxy.ts +48 -0
- package/template/nextjs/src/scripts/reset-db.ts +22 -0
- package/template/nextjs/src/scripts/seed.ts +59 -8
- package/template/nextjs/src/scripts/test-mail.ts +28 -20
- package/template/nextjs/tests/admin/users.test.ts +64 -0
- package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
- package/template/nextjs/tests/db/seed.test.ts +49 -0
- package/template/nextjs/tests/helpers/action.ts +71 -0
- package/template/nextjs/tests/helpers/fixtures.ts +78 -0
- package/template/nextjs/tests/helpers/index.ts +2 -0
- package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
- package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/nextjs/tests/lib/errors.test.ts +10 -0
- package/template/nextjs/tests/lib/search.test.ts +23 -0
- package/template/nextjs/tests/setup/pglite-db.ts +63 -0
- package/template/nextjs/tests/setup/preload.ts +95 -0
- package/template/nextjs/tsconfig.json +1 -0
- package/template/saas-starter/.dockerignore +8 -0
- package/template/saas-starter/.env.example +25 -0
- package/template/saas-starter/.vscode/extensions.json +11 -0
- package/template/saas-starter/.vscode/settings.json +3 -0
- package/template/saas-starter/AGENTS.md +302 -0
- package/template/saas-starter/CLAUDE.md +1 -0
- package/template/saas-starter/Dockerfile +15 -0
- package/template/saas-starter/README.md +125 -0
- package/template/saas-starter/bunfig.toml +3 -0
- package/template/saas-starter/components.json +25 -0
- package/template/saas-starter/drizzle.config.ts +10 -0
- package/template/saas-starter/eslint.config.mjs +24 -0
- package/template/saas-starter/gitignore +14 -0
- package/template/saas-starter/next.config.ts +32 -0
- package/template/saas-starter/package.json +97 -0
- package/template/saas-starter/postcss.config.mjs +7 -0
- package/template/saas-starter/prettier.config.mjs +11 -0
- package/template/saas-starter/public/logo-dark.svg +4 -0
- package/template/saas-starter/public/logo.svg +4 -0
- package/template/saas-starter/public/placeholder.svg +1 -0
- package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
- package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +183 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
- package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +53 -0
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +60 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +377 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +216 -0
- package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
- package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +189 -0
- package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
- package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +31 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +92 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +357 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +130 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +222 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +115 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +168 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +604 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +33 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +55 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +31 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +26 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +30 -0
- package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +37 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +200 -0
- package/template/saas-starter/src/app/(app)/app/layout.tsx +64 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +117 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +40 -0
- package/template/saas-starter/src/app/(app)/app/page.tsx +76 -0
- package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +131 -0
- package/template/saas-starter/src/app/(auth)/login/page.tsx +105 -0
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +137 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +94 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +153 -0
- package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +48 -0
- package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +24 -0
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +63 -0
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +41 -0
- package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
- package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
- package/template/saas-starter/src/app/actions/uploads.ts +36 -0
- package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
- package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
- package/template/saas-starter/src/app/error.tsx +37 -0
- package/template/saas-starter/src/app/favicon.ico +0 -0
- package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/index.ts +60 -0
- package/template/saas-starter/src/app/global-error.tsx +38 -0
- package/template/saas-starter/src/app/globals.css +269 -0
- package/template/saas-starter/src/app/layout.tsx +77 -0
- package/template/saas-starter/src/app/not-found.tsx +49 -0
- package/template/saas-starter/src/app/robots.ts +36 -0
- package/template/saas-starter/src/app/sitemap.ts +21 -0
- package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +70 -0
- package/template/saas-starter/src/components/data-table/enum.ts +5 -0
- package/template/saas-starter/src/components/data-table/features.ts +49 -0
- package/template/saas-starter/src/components/data-table/index.tsx +961 -0
- package/template/saas-starter/src/components/data-table/types.ts +76 -0
- package/template/saas-starter/src/components/error.tsx +71 -0
- package/template/saas-starter/src/components/file-upload.tsx +254 -0
- package/template/saas-starter/src/components/loader.tsx +41 -0
- package/template/saas-starter/src/components/logo.tsx +49 -0
- package/template/saas-starter/src/components/navigation-search.tsx +140 -0
- package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
- package/template/saas-starter/src/components/page-heading.tsx +38 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +51 -0
- package/template/saas-starter/src/components/route-error.tsx +55 -0
- package/template/saas-starter/src/components/screen-size.tsx +76 -0
- package/template/saas-starter/src/components/site-footer.tsx +39 -0
- package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
- package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
- package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
- package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
- package/template/saas-starter/src/components/theme-provider.tsx +115 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +75 -0
- package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
- package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
- package/template/saas-starter/src/components/ui/badge.tsx +54 -0
- package/template/saas-starter/src/components/ui/button.tsx +69 -0
- package/template/saas-starter/src/components/ui/card.tsx +103 -0
- package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
- package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
- package/template/saas-starter/src/components/ui/command.tsx +192 -0
- package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
- package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/saas-starter/src/components/ui/field.tsx +238 -0
- package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
- package/template/saas-starter/src/components/ui/input.tsx +19 -0
- package/template/saas-starter/src/components/ui/label.tsx +24 -0
- package/template/saas-starter/src/components/ui/popover.tsx +89 -0
- package/template/saas-starter/src/components/ui/select.tsx +195 -0
- package/template/saas-starter/src/components/ui/separator.tsx +28 -0
- package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
- package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
- package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
- package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
- package/template/saas-starter/src/components/ui/switch.tsx +33 -0
- package/template/saas-starter/src/components/ui/table.tsx +116 -0
- package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
- package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
- package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
- package/template/saas-starter/src/components/user-menu.tsx +213 -0
- package/template/saas-starter/src/db/common.ts +63 -0
- package/template/saas-starter/src/db/enums.ts +50 -0
- package/template/saas-starter/src/db/index.ts +60 -0
- package/template/saas-starter/src/db/relations.ts +112 -0
- package/template/saas-starter/src/db/schema.ts +342 -0
- package/template/saas-starter/src/emails/components/email-layout.tsx +79 -0
- package/template/saas-starter/src/emails/invitation.tsx +69 -0
- package/template/saas-starter/src/emails/magic-link.tsx +51 -0
- package/template/saas-starter/src/emails/test.tsx +34 -0
- package/template/saas-starter/src/emails/welcome.tsx +37 -0
- package/template/saas-starter/src/env.ts +66 -0
- package/template/saas-starter/src/hooks/use-mobile.ts +21 -0
- package/template/saas-starter/src/instrumentation.ts +44 -0
- package/template/saas-starter/src/lib/actions.ts +55 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +63 -0
- package/template/saas-starter/src/lib/app-navigation.ts +63 -0
- package/template/saas-starter/src/lib/auth-client.ts +34 -0
- package/template/saas-starter/src/lib/auth-permissions.ts +61 -0
- package/template/saas-starter/src/lib/auth.ts +312 -0
- package/template/saas-starter/src/lib/billing.ts +152 -0
- package/template/saas-starter/src/lib/callback-url.ts +28 -0
- package/template/saas-starter/src/lib/cn.ts +4 -0
- package/template/saas-starter/src/lib/constants.ts +91 -0
- package/template/saas-starter/src/lib/delay.ts +12 -0
- package/template/saas-starter/src/lib/email-service.ts +118 -0
- package/template/saas-starter/src/lib/errors.ts +74 -0
- package/template/saas-starter/src/lib/format-currency.ts +25 -0
- package/template/saas-starter/src/lib/format-date.ts +75 -0
- package/template/saas-starter/src/lib/image-url.ts +54 -0
- package/template/saas-starter/src/lib/nodemailer.ts +67 -0
- package/template/saas-starter/src/lib/organization-access.ts +73 -0
- package/template/saas-starter/src/lib/otel.ts +117 -0
- package/template/saas-starter/src/lib/plan-limits.ts +37 -0
- package/template/saas-starter/src/lib/plans.ts +90 -0
- package/template/saas-starter/src/lib/rbac.ts +44 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +126 -0
- package/template/saas-starter/src/lib/safe-action.ts +187 -0
- package/template/saas-starter/src/lib/search.ts +16 -0
- package/template/saas-starter/src/lib/seo.ts +7 -0
- package/template/saas-starter/src/lib/siteConfig.ts +20 -0
- package/template/saas-starter/src/lib/storage.ts +311 -0
- package/template/saas-starter/src/lib/stripe.ts +9 -0
- package/template/saas-starter/src/lib/toast-message.ts +101 -0
- package/template/saas-starter/src/lib/validations.ts +95 -0
- package/template/saas-starter/src/proxy.ts +48 -0
- package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
- package/template/saas-starter/src/scripts/reset-db.ts +22 -0
- package/template/saas-starter/src/scripts/seed.ts +85 -0
- package/template/saas-starter/src/scripts/setup-db.ts +72 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +206 -0
- package/template/saas-starter/src/scripts/test-mail.ts +43 -0
- package/template/saas-starter/tests/admin/users.test.ts +64 -0
- package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
- package/template/saas-starter/tests/db/seed.test.ts +57 -0
- package/template/saas-starter/tests/helpers/action.ts +71 -0
- package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
- package/template/saas-starter/tests/helpers/index.ts +2 -0
- package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
- package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/saas-starter/tests/lib/errors.test.ts +10 -0
- package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
- package/template/saas-starter/tests/lib/search.test.ts +23 -0
- package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
- package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
- package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
- package/template/saas-starter/tests/setup/preload.ts +108 -0
- package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
- package/template/saas-starter/tsconfig.json +52 -0
- package/cli.js +0 -143
- package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
- package/template/nextjs/src/app/(app)/layout.tsx +0 -20
- package/template/nextjs/terraform/artifact-registry.tf +0 -5
- package/template/nextjs/terraform/backend-service.tf +0 -13
- package/template/nextjs/terraform/cleanup.sh +0 -9
- package/template/nextjs/terraform/cloudbuild.tf +0 -70
- package/template/nextjs/terraform/cloudrun.tf +0 -68
- package/template/nextjs/terraform/database.tf +0 -60
- package/template/nextjs/terraform/forwarding-rule.tf +0 -43
- package/template/nextjs/terraform/gitignore +0 -4
- package/template/nextjs/terraform/ip-address.tf +0 -11
- package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
- package/template/nextjs/terraform/network.tf +0 -21
- package/template/nextjs/terraform/pre.sh +0 -3
- package/template/nextjs/terraform/providers.tf +0 -18
- package/template/nextjs/terraform/secret.txt +0 -11
- package/template/nextjs/terraform/setup.sh +0 -25
- package/template/nextjs/terraform/ssl-certs.tf +0 -32
- package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
- package/template/nextjs/terraform/target-https-proxy.tf +0 -14
- package/template/nextjs/terraform/terraform.tfvars +0 -4
- package/template/nextjs/terraform/url-map.tf +0 -23
- package/template/nextjs/terraform/variables.tf +0 -21
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { render, toPlainText } from '@react-email/render'
|
|
4
|
+
import InvitationEmail, { InvitationEmailProps } from '~/emails/invitation'
|
|
5
|
+
import MagicLinkEmail, { MagicLinkEmailProps } from '~/emails/magic-link'
|
|
6
|
+
import WelcomeEmail, { WelcomeEmailProps } from '~/emails/welcome'
|
|
7
|
+
import { sendEmail } from './nodemailer'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mapping of template types to their corresponding props
|
|
11
|
+
*/
|
|
12
|
+
type EmailTemplateMap = {
|
|
13
|
+
welcome: WelcomeEmailProps
|
|
14
|
+
magicLink: MagicLinkEmailProps
|
|
15
|
+
invitation: InvitationEmailProps
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type EmailTemplateType = keyof EmailTemplateMap
|
|
19
|
+
|
|
20
|
+
// Map of email template components with proper type safety
|
|
21
|
+
const componentMap: Record<
|
|
22
|
+
EmailTemplateType,
|
|
23
|
+
(props: EmailTemplateMap[EmailTemplateType]) => React.ReactElement
|
|
24
|
+
> = {
|
|
25
|
+
welcome: props => WelcomeEmail(props as WelcomeEmailProps),
|
|
26
|
+
magicLink: props => MagicLinkEmail(props as MagicLinkEmailProps),
|
|
27
|
+
invitation: props => InvitationEmail(props as InvitationEmailProps)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generic email template renderer with type-safe overloads
|
|
32
|
+
*/
|
|
33
|
+
async function renderEmailTemplate<T extends EmailTemplateType>(
|
|
34
|
+
templateType: T,
|
|
35
|
+
props: EmailTemplateMap[T]
|
|
36
|
+
): Promise<{ html: string; text: string }>
|
|
37
|
+
|
|
38
|
+
async function renderEmailTemplate(
|
|
39
|
+
templateType: EmailTemplateType,
|
|
40
|
+
props: EmailTemplateMap[EmailTemplateType]
|
|
41
|
+
) {
|
|
42
|
+
'use step'
|
|
43
|
+
|
|
44
|
+
const emailComponent = componentMap[templateType](props)
|
|
45
|
+
|
|
46
|
+
const html = await render(emailComponent)
|
|
47
|
+
const text = toPlainText(html)
|
|
48
|
+
return { html, text }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sends welcome email to newly registered users
|
|
53
|
+
*
|
|
54
|
+
* @param to - Recipient email address
|
|
55
|
+
* @param props - Welcome email template props
|
|
56
|
+
* @returns Promise resolving to SentMessageInfo from nodemailer
|
|
57
|
+
* @throws Error if sending the email fails
|
|
58
|
+
*/
|
|
59
|
+
export async function sendWelcomeEmail(to: string, props: WelcomeEmailProps) {
|
|
60
|
+
'use workflow'
|
|
61
|
+
|
|
62
|
+
const { html, text } = await renderEmailTemplate('welcome', props)
|
|
63
|
+
|
|
64
|
+
return await sendEmail({
|
|
65
|
+
to,
|
|
66
|
+
subject: `Welcome to ${props.companyName}! 🎉`,
|
|
67
|
+
html,
|
|
68
|
+
text
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sends magic link email for passwordless authentication
|
|
74
|
+
*
|
|
75
|
+
* @param to - Recipient email address
|
|
76
|
+
* @param props - Magic link email template props
|
|
77
|
+
* @returns Promise resolving to SentMessageInfo from nodemailer
|
|
78
|
+
* @throws Error if sending the email fails
|
|
79
|
+
*/
|
|
80
|
+
export async function sendMagicLinkEmail(
|
|
81
|
+
to: string,
|
|
82
|
+
props: MagicLinkEmailProps
|
|
83
|
+
) {
|
|
84
|
+
'use workflow'
|
|
85
|
+
|
|
86
|
+
const { html, text } = await renderEmailTemplate('magicLink', props)
|
|
87
|
+
|
|
88
|
+
return await sendEmail({
|
|
89
|
+
to,
|
|
90
|
+
subject: `Sign in to ${props.companyName}`,
|
|
91
|
+
html,
|
|
92
|
+
text
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sends an organization invitation email
|
|
98
|
+
*
|
|
99
|
+
* @param to - Recipient email address
|
|
100
|
+
* @param props - Invitation email template props
|
|
101
|
+
* @returns Promise resolving to SentMessageInfo from nodemailer
|
|
102
|
+
* @throws Error if sending the email fails
|
|
103
|
+
*/
|
|
104
|
+
export async function sendInvitationEmail(
|
|
105
|
+
to: string,
|
|
106
|
+
props: InvitationEmailProps
|
|
107
|
+
) {
|
|
108
|
+
'use workflow'
|
|
109
|
+
|
|
110
|
+
const { html, text } = await renderEmailTemplate('invitation', props)
|
|
111
|
+
|
|
112
|
+
return await sendEmail({
|
|
113
|
+
to,
|
|
114
|
+
subject: `${props.inviterName} invited you to join ${props.organizationName}`,
|
|
115
|
+
html,
|
|
116
|
+
text
|
|
117
|
+
})
|
|
118
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expected, user-safe action failures.
|
|
3
|
+
*
|
|
4
|
+
* Throw these for domain and authorization failures. The safe-action
|
|
5
|
+
* `handleServerError` returns {@link AppError.message} to the client and
|
|
6
|
+
* records the code on the active span. Unexpected errors stay masked.
|
|
7
|
+
*
|
|
8
|
+
* Do not put stack traces, SQL, or internal IDs in `message`.
|
|
9
|
+
*/
|
|
10
|
+
export const AppErrorCode = {
|
|
11
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
12
|
+
FORBIDDEN: 'FORBIDDEN',
|
|
13
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
14
|
+
CONFLICT: 'CONFLICT',
|
|
15
|
+
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
|
|
16
|
+
FEATURE_NOT_INCLUDED: 'FEATURE_NOT_INCLUDED',
|
|
17
|
+
BAD_REQUEST: 'BAD_REQUEST',
|
|
18
|
+
INTERNAL: 'INTERNAL'
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode]
|
|
22
|
+
|
|
23
|
+
export class AppError extends Error {
|
|
24
|
+
readonly code: AppErrorCode
|
|
25
|
+
readonly meta?: Record<string, unknown>
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
code: AppErrorCode,
|
|
29
|
+
message: string,
|
|
30
|
+
meta?: Record<string, unknown>
|
|
31
|
+
) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'AppError'
|
|
34
|
+
this.code = code
|
|
35
|
+
this.meta = meta
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isAppError(error: unknown): error is AppError {
|
|
40
|
+
return error instanceof AppError
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The Postgres SQLSTATE of a failed query. Drizzle wraps driver errors and
|
|
45
|
+
* keeps the original on `cause`, so read both.
|
|
46
|
+
*/
|
|
47
|
+
function postgresCode(error: unknown, depth = 0): unknown {
|
|
48
|
+
// Drizzle adds one layer; the cap stops an error whose cause points back at
|
|
49
|
+
// itself from looping forever.
|
|
50
|
+
if (depth > 5 || typeof error !== 'object' || error === null) return undefined
|
|
51
|
+
if ('code' in error && typeof error.code === 'string') return error.code
|
|
52
|
+
return 'cause' in error ? postgresCode(error.cause, depth + 1) : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isUniqueViolation(error: unknown): boolean {
|
|
56
|
+
return postgresCode(error) === '23505'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* True when a delete or update is refused because another row still points at
|
|
61
|
+
* the row: a foreign key violation, or a `restrict` violation for keys declared
|
|
62
|
+
* `onDelete: 'restrict'`.
|
|
63
|
+
*/
|
|
64
|
+
export function isForeignKeyViolation(error: unknown): boolean {
|
|
65
|
+
const code = postgresCode(error)
|
|
66
|
+
return code === '23503' || code === '23001'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function rethrowUniqueViolation(error: unknown, message: string): never {
|
|
70
|
+
if (isUniqueViolation(error)) {
|
|
71
|
+
throw new AppError(AppErrorCode.CONFLICT, message)
|
|
72
|
+
}
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const formatters = new Map<string, Intl.NumberFormat>()
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Formats an amount in its currency, such as `$20.00` or `€20.00`.
|
|
5
|
+
* `Intl` picks the symbol and the minor digits from the ISO currency code.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* formatCurrency('USD', 20) // '$20.00'
|
|
9
|
+
*/
|
|
10
|
+
export function formatCurrency(
|
|
11
|
+
currencyCode: string,
|
|
12
|
+
value: number | string
|
|
13
|
+
): string {
|
|
14
|
+
let formatter = formatters.get(currencyCode)
|
|
15
|
+
if (!formatter) {
|
|
16
|
+
formatter = new Intl.NumberFormat('en-US', {
|
|
17
|
+
style: 'currency',
|
|
18
|
+
currency: currencyCode,
|
|
19
|
+
currencyDisplay: 'narrowSymbol',
|
|
20
|
+
signDisplay: 'negative'
|
|
21
|
+
})
|
|
22
|
+
formatters.set(currencyCode, formatter)
|
|
23
|
+
}
|
|
24
|
+
return formatter.format(Number(value))
|
|
25
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type DateFormatOptions = {
|
|
2
|
+
/** Abbreviated month ("Jan") instead of the full name. */
|
|
3
|
+
short?: boolean
|
|
4
|
+
/** Append the time of day. */
|
|
5
|
+
time?: boolean
|
|
6
|
+
/** Drop the year. */
|
|
7
|
+
noYear?: boolean
|
|
8
|
+
/** Render in this IANA zone instead of the viewer's. */
|
|
9
|
+
timeZone?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function asValidDate(
|
|
13
|
+
date: Date | string | number | null | undefined
|
|
14
|
+
): Date | null {
|
|
15
|
+
if (date == null) return null
|
|
16
|
+
const instant = new Date(date)
|
|
17
|
+
return Number.isFinite(instant.getTime()) ? instant : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Date and time are formatted separately and joined with a fixed " at ",
|
|
22
|
+
* because the connector ICU inserts between them changed across versions
|
|
23
|
+
* (", " in ICU 72 and later). Bun, Node, and each browser ship different ICU
|
|
24
|
+
* data, so one `toLocaleString` call renders differently per viewer and
|
|
25
|
+
* breaks hydration.
|
|
26
|
+
*/
|
|
27
|
+
function joinDateAndTime(
|
|
28
|
+
instant: Date,
|
|
29
|
+
dateOptions: Intl.DateTimeFormatOptions,
|
|
30
|
+
timeOptions: Intl.DateTimeFormatOptions,
|
|
31
|
+
timeZone: string | undefined
|
|
32
|
+
): string {
|
|
33
|
+
const zone = timeZone ? { timeZone } : {}
|
|
34
|
+
const datePart = instant.toLocaleDateString('en-US', {
|
|
35
|
+
...dateOptions,
|
|
36
|
+
...zone
|
|
37
|
+
})
|
|
38
|
+
const timePart = instant.toLocaleTimeString('en-US', {
|
|
39
|
+
...timeOptions,
|
|
40
|
+
...zone
|
|
41
|
+
})
|
|
42
|
+
return `${datePart} at ${timePart}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Formats a date as an `en-US` string, such as "March 3, 2026".
|
|
47
|
+
* Returns `-` for a missing or invalid date.
|
|
48
|
+
*/
|
|
49
|
+
export const formatDate = (
|
|
50
|
+
date: Date | string | number | null | undefined,
|
|
51
|
+
options?: DateFormatOptions
|
|
52
|
+
): string => {
|
|
53
|
+
const instant = asValidDate(date)
|
|
54
|
+
if (!instant) return '-'
|
|
55
|
+
|
|
56
|
+
const dateOptions: Intl.DateTimeFormatOptions = {
|
|
57
|
+
...(options?.noYear ? {} : { year: 'numeric' }),
|
|
58
|
+
month: options?.short ? 'short' : 'long',
|
|
59
|
+
day: 'numeric'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!options?.time) {
|
|
63
|
+
return instant.toLocaleDateString('en-US', {
|
|
64
|
+
...dateOptions,
|
|
65
|
+
...(options?.timeZone ? { timeZone: options.timeZone } : {})
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return joinDateAndTime(
|
|
70
|
+
instant,
|
|
71
|
+
dateOptions,
|
|
72
|
+
{ hour: '2-digit', minute: '2-digit' },
|
|
73
|
+
options.timeZone
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image field formats stored on organizations.
|
|
3
|
+
*
|
|
4
|
+
* Client-safe: no env, DB, or server-only imports. Zod schemas and
|
|
5
|
+
* `resolveImageUrl` share these predicates so only R2 keys and data image
|
|
6
|
+
* URLs reach the renderer.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
|
|
10
|
+
type R2UploadMimeType
|
|
11
|
+
} from '~/lib/constants'
|
|
12
|
+
|
|
13
|
+
const IMAGE_EXTENSIONS = Object.values(R2_UPLOAD_EXTENSION_BY_MIME_TYPE)
|
|
14
|
+
const IMAGE_SUBTYPES = (
|
|
15
|
+
Object.keys(R2_UPLOAD_EXTENSION_BY_MIME_TYPE) as R2UploadMimeType[]
|
|
16
|
+
).map(mime => mime.slice('image/'.length))
|
|
17
|
+
|
|
18
|
+
const R2_OBJECT_KEY_RE = new RegExp(
|
|
19
|
+
`^uploads/organizations/[a-z0-9_-]+/[a-zA-Z0-9._-]+\\.(${IMAGE_EXTENSIONS.join('|')})$`
|
|
20
|
+
)
|
|
21
|
+
const DATA_IMAGE_URL_RE = new RegExp(
|
|
22
|
+
`^data:image/(?:${IMAGE_SUBTYPES.join('|')});base64,[A-Za-z0-9+/]+={0,2}$`
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
/** True when `value` is an owned-upload key (`uploads/organizations/<id>/<file>.<ext>`). */
|
|
26
|
+
export function isR2ObjectKey(value: string): boolean {
|
|
27
|
+
return R2_OBJECT_KEY_RE.test(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when `value` is a `data:image/*;base64,...` URL of an allowed MIME type. */
|
|
31
|
+
export function isDataImageUrl(value: string): boolean {
|
|
32
|
+
return DATA_IMAGE_URL_RE.test(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when `value` is an R2 object key or a data image URL. */
|
|
36
|
+
export function isStoredImageValue(value: string): boolean {
|
|
37
|
+
return isR2ObjectKey(value) || isDataImageUrl(value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Persist rules after format checks.
|
|
42
|
+
*
|
|
43
|
+
* R2 keys are always eligible (ownership is a separate DB check). Data URLs
|
|
44
|
+
* are only eligible when R2 is off; otherwise they skip the upload tracking
|
|
45
|
+
* and inline megabytes into every page that shows the image.
|
|
46
|
+
*/
|
|
47
|
+
export function canPersistImageValue(
|
|
48
|
+
value: string,
|
|
49
|
+
r2Configured: boolean
|
|
50
|
+
): boolean {
|
|
51
|
+
if (isR2ObjectKey(value)) return true
|
|
52
|
+
if (isDataImageUrl(value)) return !r2Configured
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import nodemailer, { SentMessageInfo } from 'nodemailer'
|
|
4
|
+
import { env } from '~/env'
|
|
5
|
+
import { AppError, AppErrorCode } from './errors'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configure the email transporter using environment variables.
|
|
9
|
+
* Uses nodemailer for sending emails via SMTP.
|
|
10
|
+
*/
|
|
11
|
+
const transporter = nodemailer.createTransport({
|
|
12
|
+
host: env.EMAIL_SERVER_HOST,
|
|
13
|
+
port: env.EMAIL_SERVER_PORT,
|
|
14
|
+
secure: env.EMAIL_SERVER_PORT === 465, // Use secure for port 465, false for other ports
|
|
15
|
+
auth: {
|
|
16
|
+
user: env.EMAIL_SERVER_USER,
|
|
17
|
+
pass: env.EMAIL_SERVER_PASSWORD
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Interface for the email payload
|
|
23
|
+
*/
|
|
24
|
+
interface SendEmailParams {
|
|
25
|
+
to: string
|
|
26
|
+
subject: string
|
|
27
|
+
text?: string
|
|
28
|
+
html?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sends an email using the configured transporter.
|
|
33
|
+
*
|
|
34
|
+
* @param {SendEmailParams} params - The email parameters.
|
|
35
|
+
* @param {string} params.to - The recipient's email address.
|
|
36
|
+
* @param {string} params.subject - The subject of the email.
|
|
37
|
+
* @param {string} [params.text] - The plain text content of the email.
|
|
38
|
+
* @param {string} [params.html] - The HTML content of the email (optional).
|
|
39
|
+
* @returns {Promise<any>} Resolves when the email is sent successfully.
|
|
40
|
+
* @throws {Error} If sending the email fails.
|
|
41
|
+
*/
|
|
42
|
+
export const sendEmail = async ({
|
|
43
|
+
to,
|
|
44
|
+
subject,
|
|
45
|
+
text,
|
|
46
|
+
html
|
|
47
|
+
}: SendEmailParams): Promise<SentMessageInfo> => {
|
|
48
|
+
'use step'
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const info = await transporter.sendMail({
|
|
52
|
+
from: env.EMAIL_FROM,
|
|
53
|
+
to,
|
|
54
|
+
subject,
|
|
55
|
+
text,
|
|
56
|
+
html
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return info
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error('Error sending email:', error)
|
|
62
|
+
throw new AppError(
|
|
63
|
+
AppErrorCode.INTERNAL,
|
|
64
|
+
'The email was not sent. Try again.'
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { and, eq, inArray } from 'drizzle-orm'
|
|
4
|
+
import { db } from '~/db'
|
|
5
|
+
import { membersTable } from '~/db/schema'
|
|
6
|
+
import { AppError, AppErrorCode } from './errors'
|
|
7
|
+
import type { OrganizationRole } from './rbac'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The caller's membership in an organization, or null when they have none.
|
|
11
|
+
*/
|
|
12
|
+
export async function getMembership(organizationId: string, userId: string) {
|
|
13
|
+
const membership = await db.query.membersTable.findFirst({
|
|
14
|
+
where: and(
|
|
15
|
+
eq(membersTable.organizationId, organizationId),
|
|
16
|
+
eq(membersTable.userId, userId)
|
|
17
|
+
),
|
|
18
|
+
columns: { id: true, role: true }
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
return membership ?? null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Asserts the caller belongs to the organization, in any role.
|
|
26
|
+
* Throws {@link AppError} with code FORBIDDEN otherwise.
|
|
27
|
+
*/
|
|
28
|
+
export async function assertMember(organizationId: string, userId: string) {
|
|
29
|
+
const membership = await getMembership(organizationId, userId)
|
|
30
|
+
if (!membership) {
|
|
31
|
+
throw new AppError(
|
|
32
|
+
AppErrorCode.FORBIDDEN,
|
|
33
|
+
'You do not have access to this organization.'
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
return membership
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Asserts the caller holds one of `allowedRoles` in the organization.
|
|
41
|
+
*/
|
|
42
|
+
export async function assertRole(
|
|
43
|
+
organizationId: string,
|
|
44
|
+
userId: string,
|
|
45
|
+
allowedRoles: ReadonlyArray<OrganizationRole>
|
|
46
|
+
) {
|
|
47
|
+
const membership = await db.query.membersTable.findFirst({
|
|
48
|
+
where: and(
|
|
49
|
+
eq(membersTable.organizationId, organizationId),
|
|
50
|
+
eq(membersTable.userId, userId),
|
|
51
|
+
inArray(membersTable.role, [...allowedRoles])
|
|
52
|
+
),
|
|
53
|
+
columns: { id: true, role: true }
|
|
54
|
+
})
|
|
55
|
+
if (!membership) {
|
|
56
|
+
throw new AppError(
|
|
57
|
+
AppErrorCode.FORBIDDEN,
|
|
58
|
+
'You do not have permission to perform this action.'
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
return membership
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Asserts the caller can manage the organization (owner or admin). Use it for
|
|
66
|
+
* settings, billing, members, and uploads.
|
|
67
|
+
*/
|
|
68
|
+
export function assertCanManageOrganization(
|
|
69
|
+
organizationId: string,
|
|
70
|
+
userId: string
|
|
71
|
+
) {
|
|
72
|
+
return assertRole(organizationId, userId, ['owner', 'admin'])
|
|
73
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { SpanStatusCode, trace, type Attributes } from '@opentelemetry/api'
|
|
2
|
+
|
|
3
|
+
const MAX_STRING_ATTR_LENGTH = 256
|
|
4
|
+
const MAX_ARRAY_ATTR_ITEMS = 20
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Helper function to retrieve the currently active span from the OpenTelemetry context.
|
|
8
|
+
* Useful for attaching additional information to the current trace.
|
|
9
|
+
*/
|
|
10
|
+
function getSpan() {
|
|
11
|
+
const span = trace.getActiveSpan()
|
|
12
|
+
return span
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Records a "wide event" with arbitrary attributes to the current span.
|
|
17
|
+
* This allows adding high-cardinality data or domain-specific context
|
|
18
|
+
* to the trace without starting a new child span.
|
|
19
|
+
*
|
|
20
|
+
* @param attributes - Key-value pairs of attributes to add to the span
|
|
21
|
+
*/
|
|
22
|
+
export function recordWideEvent(attributes: Attributes) {
|
|
23
|
+
const span = getSpan()
|
|
24
|
+
if (!span) return
|
|
25
|
+
|
|
26
|
+
span.setAttributes(attributes)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Flatten a value tree into OTEL-safe attributes under `prefix`.
|
|
31
|
+
* Nested objects use dotted keys; long strings are truncated; large
|
|
32
|
+
* arrays are capped. Use for action `parsedInput` on wide events.
|
|
33
|
+
*/
|
|
34
|
+
export function flattenForWideEvent(
|
|
35
|
+
value: unknown,
|
|
36
|
+
prefix = 'input'
|
|
37
|
+
): Attributes {
|
|
38
|
+
const out: Attributes = {}
|
|
39
|
+
|
|
40
|
+
const visit = (current: unknown, path: string) => {
|
|
41
|
+
if (current === null || current === undefined) return
|
|
42
|
+
|
|
43
|
+
if (typeof current === 'string') {
|
|
44
|
+
out[path] =
|
|
45
|
+
current.length > MAX_STRING_ATTR_LENGTH
|
|
46
|
+
? `${current.slice(0, MAX_STRING_ATTR_LENGTH)}…`
|
|
47
|
+
: current
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof current === 'number' || typeof current === 'boolean') {
|
|
52
|
+
out[path] = current
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof current === 'bigint') {
|
|
57
|
+
out[path] = current.toString()
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Array.isArray(current)) {
|
|
62
|
+
const limited = current.slice(0, MAX_ARRAY_ATTR_ITEMS)
|
|
63
|
+
const allPrimitive = limited.every(
|
|
64
|
+
item =>
|
|
65
|
+
item === null ||
|
|
66
|
+
item === undefined ||
|
|
67
|
+
typeof item === 'string' ||
|
|
68
|
+
typeof item === 'number' ||
|
|
69
|
+
typeof item === 'boolean'
|
|
70
|
+
)
|
|
71
|
+
if (allPrimitive) {
|
|
72
|
+
out[path] = limited.map(item =>
|
|
73
|
+
item === null || item === undefined ? '' : String(item)
|
|
74
|
+
)
|
|
75
|
+
if (current.length > MAX_ARRAY_ATTR_ITEMS) {
|
|
76
|
+
out[`${path}.length`] = current.length
|
|
77
|
+
}
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
out[`${path}.length`] = current.length
|
|
81
|
+
limited.forEach((item, index) => visit(item, `${path}.${index}`))
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (typeof current === 'object') {
|
|
86
|
+
for (const [key, child] of Object.entries(
|
|
87
|
+
current as Record<string, unknown>
|
|
88
|
+
)) {
|
|
89
|
+
visit(child, path ? `${path}.${key}` : key)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
visit(value, prefix)
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Records an exception to the current span and sets the span status to ERROR.
|
|
100
|
+
* captures the error message and stack trace if available.
|
|
101
|
+
*
|
|
102
|
+
* @param err - The error to record (can be an Error object or unknown)
|
|
103
|
+
* @returns The trace ID associated with the error, useful for correlation
|
|
104
|
+
*/
|
|
105
|
+
export function recordError(err: unknown) {
|
|
106
|
+
const span = getSpan()
|
|
107
|
+
if (!span) return
|
|
108
|
+
|
|
109
|
+
span.setStatus({
|
|
110
|
+
code: SpanStatusCode.ERROR,
|
|
111
|
+
message: err instanceof Error ? err.message : String(err)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
span.recordException(err instanceof Error ? err : String(err))
|
|
115
|
+
|
|
116
|
+
return span.spanContext().traceId
|
|
117
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { eq } from 'drizzle-orm'
|
|
4
|
+
import { cache } from 'react'
|
|
5
|
+
import { db } from '~/db'
|
|
6
|
+
import { organizationSubscriptionsTable } from '~/db/schema'
|
|
7
|
+
import { AppError, AppErrorCode } from './errors'
|
|
8
|
+
import { PLANS, isActiveSubscriptionStatus } from './plans'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The plan an organization is entitled to right now, and its subscription row.
|
|
12
|
+
*
|
|
13
|
+
* Only a trialing, active, or past-due subscription grants its paid plan;
|
|
14
|
+
* every other status falls back to the free plan.
|
|
15
|
+
*/
|
|
16
|
+
export const getOrganizationPlan = cache(async (organizationId: string) => {
|
|
17
|
+
const sub = await db.query.organizationSubscriptionsTable.findFirst({
|
|
18
|
+
where: eq(organizationSubscriptionsTable.organizationId, organizationId)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
if (!sub) {
|
|
22
|
+
throw new AppError(
|
|
23
|
+
AppErrorCode.NOT_FOUND,
|
|
24
|
+
'This organization has no subscription record.'
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const planId = isActiveSubscriptionStatus(sub.status) ? sub.plan : 'free'
|
|
29
|
+
const plan = PLANS.find(candidate => candidate.id === planId)
|
|
30
|
+
|
|
31
|
+
if (!plan) {
|
|
32
|
+
// A configuration bug, not a customer error: masked and recorded.
|
|
33
|
+
throw new Error(`Plan ${planId} is missing from the plan registry.`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { plan, sub }
|
|
37
|
+
})
|