create-ej-app 25.0.0 → 26.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.
Files changed (160) hide show
  1. package/cli.js +4 -0
  2. package/cli.ts +5 -0
  3. package/package.json +2 -2
  4. package/template/api/package.json +2 -2
  5. package/template/nextjs/gitignore +1 -0
  6. package/template/nextjs/package.json +18 -18
  7. package/template/saas-starter/.dockerignore +7 -0
  8. package/template/saas-starter/.env.example +25 -0
  9. package/template/saas-starter/.vscode/extensions.json +11 -0
  10. package/template/saas-starter/.vscode/settings.json +3 -0
  11. package/template/saas-starter/Dockerfile +15 -0
  12. package/template/saas-starter/README.md +3 -0
  13. package/template/saas-starter/components.json +25 -0
  14. package/template/saas-starter/drizzle.config.ts +10 -0
  15. package/template/saas-starter/eslint.config.mjs +23 -0
  16. package/template/saas-starter/gitignore +9 -0
  17. package/template/saas-starter/next-env.d.ts +6 -0
  18. package/template/saas-starter/next.config.ts +14 -0
  19. package/template/saas-starter/package.json +92 -0
  20. package/template/saas-starter/postcss.config.mjs +7 -0
  21. package/template/saas-starter/prettier.config.mjs +11 -0
  22. package/template/saas-starter/public/placeholder.svg +1 -0
  23. package/template/saas-starter/src/actions/uploads.ts +28 -0
  24. package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
  25. package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
  26. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -0
  27. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  28. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +40 -0
  29. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
  30. package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
  31. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
  32. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
  33. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
  34. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
  35. package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
  36. package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
  37. package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
  38. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
  39. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
  40. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
  41. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
  42. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
  43. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
  44. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
  45. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
  46. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
  47. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
  48. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
  49. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
  50. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
  51. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
  52. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
  53. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
  54. package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
  55. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
  56. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
  57. package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
  58. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
  59. package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
  60. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
  61. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
  62. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
  63. package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
  64. package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
  65. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
  66. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -0
  67. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  68. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  69. package/template/saas-starter/src/app/favicon.ico +0 -0
  70. package/template/saas-starter/src/app/globals.css +240 -0
  71. package/template/saas-starter/src/app/layout.tsx +53 -0
  72. package/template/saas-starter/src/app/not-found.tsx +27 -0
  73. package/template/saas-starter/src/app/proxy.ts +28 -0
  74. package/template/saas-starter/src/app/robots.ts +32 -0
  75. package/template/saas-starter/src/app/sitemap.ts +20 -0
  76. package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
  77. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  78. package/template/saas-starter/src/components/data-table/index.tsx +960 -0
  79. package/template/saas-starter/src/components/data-table/types.ts +75 -0
  80. package/template/saas-starter/src/components/error.tsx +26 -0
  81. package/template/saas-starter/src/components/file-upload.tsx +215 -0
  82. package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
  83. package/template/saas-starter/src/components/loader.tsx +25 -0
  84. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  85. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  86. package/template/saas-starter/src/components/page-heading.tsx +32 -0
  87. package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
  88. package/template/saas-starter/src/components/screen-size.tsx +40 -0
  89. package/template/saas-starter/src/components/site-footer.tsx +23 -0
  90. package/template/saas-starter/src/components/theme-provider.tsx +67 -0
  91. package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
  92. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  93. package/template/saas-starter/src/components/ui/badge.tsx +49 -0
  94. package/template/saas-starter/src/components/ui/button.tsx +67 -0
  95. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  96. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  97. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  98. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  99. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  100. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  101. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  102. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  103. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  104. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  105. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  106. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  107. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  108. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  109. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  110. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  111. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  112. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  113. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  114. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  115. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  116. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  117. package/template/saas-starter/src/components/user-menu.tsx +90 -0
  118. package/template/saas-starter/src/db/common.ts +31 -0
  119. package/template/saas-starter/src/db/enums.ts +26 -0
  120. package/template/saas-starter/src/db/index.ts +51 -0
  121. package/template/saas-starter/src/db/relations.ts +75 -0
  122. package/template/saas-starter/src/db/schema.ts +191 -0
  123. package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
  124. package/template/saas-starter/src/emails/invitation.tsx +56 -0
  125. package/template/saas-starter/src/emails/magic-link.tsx +49 -0
  126. package/template/saas-starter/src/emails/test.tsx +32 -0
  127. package/template/saas-starter/src/emails/welcome.tsx +34 -0
  128. package/template/saas-starter/src/env.ts +66 -0
  129. package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
  130. package/template/saas-starter/src/instrumentation.ts +26 -0
  131. package/template/saas-starter/src/lib/actions.ts +21 -0
  132. package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
  133. package/template/saas-starter/src/lib/app-navigation.ts +59 -0
  134. package/template/saas-starter/src/lib/auth-client.ts +29 -0
  135. package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
  136. package/template/saas-starter/src/lib/auth.ts +211 -0
  137. package/template/saas-starter/src/lib/billing.ts +117 -0
  138. package/template/saas-starter/src/lib/cn.ts +4 -0
  139. package/template/saas-starter/src/lib/constants.ts +22 -0
  140. package/template/saas-starter/src/lib/delay.ts +2 -0
  141. package/template/saas-starter/src/lib/email-service.ts +89 -0
  142. package/template/saas-starter/src/lib/format-currency.ts +27 -0
  143. package/template/saas-starter/src/lib/format-date.ts +12 -0
  144. package/template/saas-starter/src/lib/nodemailer.ts +43 -0
  145. package/template/saas-starter/src/lib/organization-access.ts +93 -0
  146. package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
  147. package/template/saas-starter/src/lib/otel.ts +27 -0
  148. package/template/saas-starter/src/lib/plans.ts +63 -0
  149. package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
  150. package/template/saas-starter/src/lib/safe-action.ts +67 -0
  151. package/template/saas-starter/src/lib/siteConfig.ts +4 -0
  152. package/template/saas-starter/src/lib/storage.ts +181 -0
  153. package/template/saas-starter/src/lib/stripe.ts +6 -0
  154. package/template/saas-starter/src/lib/toast-message.ts +15 -0
  155. package/template/saas-starter/src/lib/validations.ts +12 -0
  156. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  157. package/template/saas-starter/src/scripts/seed.ts +11 -0
  158. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
  159. package/template/saas-starter/src/scripts/test-mail.ts +35 -0
  160. package/template/saas-starter/tsconfig.json +51 -0
@@ -0,0 +1,22 @@
1
+ import { createAccessControl } from 'better-auth/plugins/access'
2
+ import { defaultStatements } from 'better-auth/plugins/admin/access'
3
+
4
+ const statement = {
5
+ ...defaultStatements
6
+ } as const
7
+
8
+ export const ac = createAccessControl(statement)
9
+
10
+ const user = ac.newRole({})
11
+
12
+ const admin = ac.newRole({
13
+ user: ['list', 'ban', 'impersonate', 'set-password', 'get', 'update'],
14
+ session: defaultStatements.session
15
+ })
16
+
17
+ const superadmin = ac.newRole({
18
+ user: defaultStatements.user,
19
+ session: defaultStatements.session
20
+ })
21
+
22
+ export const roles = { user, admin, superadmin }
@@ -0,0 +1,211 @@
1
+ import { validateEmail } from '@ejekanshjain/simple-email-validator'
2
+ import { createId } from '@paralleldrive/cuid2'
3
+ import { APIError, betterAuth } from 'better-auth'
4
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
5
+ import { createAuthMiddleware } from 'better-auth/api'
6
+ import {
7
+ admin,
8
+ lastLoginMethod,
9
+ magicLink,
10
+ organization
11
+ } from 'better-auth/plugins'
12
+ import { eq } from 'drizzle-orm'
13
+ import { headers } from 'next/headers'
14
+ import { cache } from 'react'
15
+ import { start } from 'workflow/api'
16
+ import { db } from '~/db'
17
+ import {
18
+ accountsTable,
19
+ invitationsTable,
20
+ membersTable,
21
+ organizationsTable,
22
+ organizationSubscriptionsTable,
23
+ sessionsTable,
24
+ usersTable,
25
+ verificationsTable
26
+ } from '~/db/schema'
27
+ import { env } from '~/env'
28
+ import { ac, roles } from './auth-permissions'
29
+ import {
30
+ sendInvitationEmail,
31
+ sendMagicLinkEmail,
32
+ sendWelcomeEmail
33
+ } from './email-service'
34
+ import { getOrganizationPlanCached } from './organization-plan-limits'
35
+ import { siteConfig } from './siteConfig'
36
+ import { stripeClient } from './stripe'
37
+
38
+ export const auth = betterAuth({
39
+ database: drizzleAdapter(db, {
40
+ provider: 'pg',
41
+ schema: {
42
+ user: usersTable,
43
+ session: sessionsTable,
44
+ account: accountsTable,
45
+ verification: verificationsTable,
46
+ organization: organizationsTable,
47
+ member: membersTable,
48
+ invitation: invitationsTable
49
+ }
50
+ }),
51
+ account: {
52
+ accountLinking: {
53
+ enabled: true
54
+ }
55
+ },
56
+ advanced: {
57
+ database: {
58
+ generateId: ({ model }) => `${model}_${createId()}`
59
+ }
60
+ },
61
+ socialProviders: {
62
+ github: {
63
+ prompt: 'select_account',
64
+ clientId: env.BETTER_AUTH_GITHUB_ID,
65
+ clientSecret: env.BETTER_AUTH_GITHUB_SECRET
66
+ },
67
+ google: {
68
+ prompt: 'select_account',
69
+ clientId: env.BETTER_AUTH_GOOGLE_ID,
70
+ clientSecret: env.BETTER_AUTH_GOOGLE_SECRET
71
+ }
72
+ },
73
+ databaseHooks: {
74
+ user: {
75
+ create: {
76
+ after: async user => {
77
+ try {
78
+ await start(sendWelcomeEmail, [
79
+ user.email,
80
+ {
81
+ userName: user.name,
82
+ companyName: siteConfig.name
83
+ }
84
+ ])
85
+ } catch (error) {
86
+ console.error('Failed to send welcome email:', error)
87
+ }
88
+ }
89
+ }
90
+ }
91
+ },
92
+ plugins: [
93
+ magicLink({
94
+ sendMagicLink: async ({ email, url }) => {
95
+ try {
96
+ await start(sendMagicLinkEmail, [
97
+ email,
98
+ {
99
+ magicLink: url,
100
+ companyName: siteConfig.name
101
+ }
102
+ ])
103
+ } catch (error) {
104
+ console.error('Failed to send magic link email:', error)
105
+ }
106
+ }
107
+ }),
108
+ admin({
109
+ ac,
110
+ roles
111
+ }),
112
+ organization({
113
+ organizationLimit: 10,
114
+ invitationExpiresIn: 7 * 24 * 60 * 60, // 7 days
115
+ cancelPendingInvitationsOnReInvite: true,
116
+ sendInvitationEmail: async ({ id, email, organization, inviter }) => {
117
+ try {
118
+ await start(sendInvitationEmail, [
119
+ email,
120
+ {
121
+ inviterName: inviter.user.name,
122
+ organizationName: organization.name,
123
+ inviteLink: `${env.BETTER_AUTH_URL}/accept-invitation/${id}`,
124
+ companyName: siteConfig.name
125
+ }
126
+ ])
127
+ } catch (error) {
128
+ console.error('Failed to send invitation email:', error)
129
+ }
130
+ },
131
+ organizationHooks: {
132
+ afterCreateOrganization: async ({ organization }) => {
133
+ await db.insert(organizationSubscriptionsTable).values({
134
+ organizationId: organization.id
135
+ })
136
+ },
137
+ beforeDeleteOrganization: async ({ organization }) => {
138
+ const sub = await db.query.organizationSubscriptionsTable.findFirst({
139
+ where: eq(
140
+ organizationSubscriptionsTable.organizationId,
141
+ organization.id
142
+ )
143
+ })
144
+
145
+ if (sub?.stripeSubscriptionId) {
146
+ try {
147
+ await stripeClient.subscriptions.cancel(sub.stripeSubscriptionId)
148
+ } catch (error) {
149
+ console.error(
150
+ `Failed to cancel Stripe subscription ${sub.stripeSubscriptionId} for org ${organization.id} on org delete:`,
151
+ error
152
+ )
153
+ throw new APIError('BAD_REQUEST', {
154
+ message:
155
+ 'Could not cancel the active subscription. Please try again later.'
156
+ })
157
+ }
158
+ }
159
+
160
+ await db
161
+ .delete(organizationSubscriptionsTable)
162
+ .where(
163
+ eq(organizationSubscriptionsTable.organizationId, organization.id)
164
+ )
165
+ }
166
+ },
167
+ invitationLimit: async ({ organization }) => {
168
+ const { plan } = await getOrganizationPlanCached(organization.id)
169
+ return plan.maxMembers
170
+ },
171
+ membershipLimit: async (_, organization) => {
172
+ const { plan } = await getOrganizationPlanCached(organization.id)
173
+ return plan.maxMembers
174
+ }
175
+ }),
176
+ lastLoginMethod()
177
+ ],
178
+ hooks: {
179
+ before: createAuthMiddleware(async ctx => {
180
+ if (ctx.path === '/sign-in/magic-link' && ctx.body?.email) {
181
+ try {
182
+ const validation = await validateEmail({
183
+ email: ctx.body.email
184
+ })
185
+
186
+ if (validation.isValid === false) {
187
+ throw new APIError('BAD_REQUEST', {
188
+ message: 'Please use a valid email address.'
189
+ })
190
+ }
191
+ } catch (err) {
192
+ console.error('Email validation failed', err)
193
+ }
194
+ }
195
+ })
196
+ }
197
+ })
198
+
199
+ export const getAuthSession = cache(async () => {
200
+ const authSession = await auth.api.getSession({
201
+ headers: await headers()
202
+ })
203
+
204
+ if (!authSession || !authSession.user || !authSession.session) return null
205
+
206
+ const role = authSession.user.role
207
+ const isSuperAdmin = role === 'superadmin'
208
+ const isAdmin = role === 'admin' || isSuperAdmin
209
+
210
+ return { ...authSession, isAdmin, isSuperAdmin }
211
+ })
@@ -0,0 +1,117 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { db } from '~/db'
3
+ import { organizationSubscriptionsTable, organizationsTable } from '~/db/schema'
4
+ import { PLANS, type Plan } from './plans'
5
+ import { stripeClient } from './stripe'
6
+
7
+ type SubscriptionRow = typeof organizationSubscriptionsTable.$inferSelect
8
+
9
+ export const getPlanAndIntervalByPriceId = (
10
+ priceId: string
11
+ ): { planId: Plan['id']; interval: 'month' | 'year' } | null => {
12
+ for (const plan of PLANS) {
13
+ if (!plan.stripePrice) continue
14
+ if (plan.stripePrice.monthly.id === priceId)
15
+ return { planId: plan.id, interval: 'month' }
16
+ if (plan.stripePrice.annual.id === priceId)
17
+ return { planId: plan.id, interval: 'year' }
18
+ }
19
+ return null
20
+ }
21
+
22
+ export const canStartTrial = (
23
+ sub: Pick<SubscriptionRow, 'status' | 'hasUsedTrial'>
24
+ ): boolean => sub.status === 'free' && !sub.hasUsedTrial
25
+
26
+ export const ensureStripeCustomer = async (
27
+ organizationId: string
28
+ ): Promise<string> => {
29
+ const sub = await db.query.organizationSubscriptionsTable.findFirst({
30
+ where: eq(organizationSubscriptionsTable.organizationId, organizationId)
31
+ })
32
+
33
+ if (!sub) {
34
+ throw new Error('Organization does not have a subscription')
35
+ }
36
+
37
+ if (sub.stripeCustomerId) return sub.stripeCustomerId
38
+
39
+ if (!sub.billingEmail) {
40
+ throw new Error('Set a billing email before subscribing')
41
+ }
42
+
43
+ const org = await db.query.organizationsTable.findFirst({
44
+ where: eq(organizationsTable.id, organizationId),
45
+ columns: { name: true }
46
+ })
47
+
48
+ const customer = await stripeClient.customers.create({
49
+ email: sub.billingEmail,
50
+ name: org?.name,
51
+ metadata: { organizationId }
52
+ })
53
+
54
+ await db
55
+ .update(organizationSubscriptionsTable)
56
+ .set({ stripeCustomerId: customer.id })
57
+ .where(eq(organizationSubscriptionsTable.organizationId, organizationId))
58
+
59
+ return customer.id
60
+ }
61
+
62
+ export const syncSubscriptionFromStripe = async (
63
+ customerId: string
64
+ ): Promise<void> => {
65
+ const subs = await stripeClient.subscriptions.list({
66
+ customer: customerId,
67
+ status: 'all',
68
+ limit: 1
69
+ })
70
+
71
+ const sub = subs.data[0]
72
+
73
+ if (
74
+ !sub ||
75
+ sub.status === 'canceled' ||
76
+ sub.status === 'incomplete_expired'
77
+ ) {
78
+ await db
79
+ .update(organizationSubscriptionsTable)
80
+ .set({
81
+ plan: 'free',
82
+ status: 'free',
83
+ interval: null,
84
+ stripeSubscriptionId: null,
85
+ trialEndsAt: null,
86
+ currentPeriodEnd: null,
87
+ cancelAtPeriodEnd: false
88
+ })
89
+ .where(eq(organizationSubscriptionsTable.stripeCustomerId, customerId))
90
+ return
91
+ }
92
+
93
+ const item = sub.items.data[0]
94
+ const resolved = item ? getPlanAndIntervalByPriceId(item.price.id) : null
95
+
96
+ if (!resolved) {
97
+ console.error(
98
+ `Stripe price ${item?.price.id} on subscription ${sub.id} does not map to a known plan`
99
+ )
100
+ }
101
+
102
+ const periodEnd = item?.current_period_end ?? null
103
+
104
+ await db
105
+ .update(organizationSubscriptionsTable)
106
+ .set({
107
+ plan: resolved?.planId ?? 'free',
108
+ status: sub.status,
109
+ interval: resolved?.interval ?? null,
110
+ stripeSubscriptionId: sub.id,
111
+ trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
112
+ currentPeriodEnd: periodEnd ? new Date(periodEnd * 1000) : null,
113
+ cancelAtPeriodEnd: sub.cancel_at_period_end,
114
+ ...(sub.status === 'trialing' ? { hasUsedTrial: true } : {})
115
+ })
116
+ .where(eq(organizationSubscriptionsTable.stripeCustomerId, customerId))
117
+ }
@@ -0,0 +1,4 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
@@ -0,0 +1,22 @@
1
+ // ============================================
2
+ // BILLING CONFIGURATION
3
+ // ============================================
4
+
5
+ /* Trial days */
6
+ export const TRIAL_DAYS = 7
7
+
8
+ // ============================================
9
+ // R2 STORAGE CONFIGURATION
10
+ // ============================================
11
+
12
+ /* Presigned URL TTL in seconds */
13
+ export const PRESIGNED_URL_TTL_SECONDS = 900 // 15 min to complete the upload
14
+
15
+ /* Temp cleanup threshold in milliseconds */
16
+ export const TEMP_CLEANUP_THRESHOLD_MS = 2 * 60 * 60 * 1000 // 2 hours
17
+
18
+ /* Maximum file size in MB */
19
+ export const MAX_FILE_SIZE_MB = 5
20
+
21
+ /* Maximum file size in bytes */
22
+ export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
@@ -0,0 +1,2 @@
1
+ export const delay = (ms: number) =>
2
+ new Promise(resolve => setTimeout(resolve, ms))
@@ -0,0 +1,89 @@
1
+ import { render, toPlainText } from '@react-email/render'
2
+ import InvitationEmail, { InvitationEmailProps } from '~/emails/invitation'
3
+ import MagicLinkEmail, { MagicLinkEmailProps } from '~/emails/magic-link'
4
+ import WelcomeEmail, { WelcomeEmailProps } from '~/emails/welcome'
5
+ import { sendEmail } from './nodemailer'
6
+
7
+ /**
8
+ * Mapping of template types to their corresponding props
9
+ */
10
+ type EmailTemplateMap = {
11
+ welcome: WelcomeEmailProps
12
+ magicLink: MagicLinkEmailProps
13
+ invitation: InvitationEmailProps
14
+ }
15
+
16
+ type EmailTemplateType = keyof EmailTemplateMap
17
+
18
+ // Map of email template components with proper type safety
19
+ const componentMap: Record<
20
+ EmailTemplateType,
21
+ (props: EmailTemplateMap[EmailTemplateType]) => React.ReactElement
22
+ > = {
23
+ welcome: props => WelcomeEmail(props as WelcomeEmailProps),
24
+ magicLink: props => MagicLinkEmail(props as MagicLinkEmailProps),
25
+ invitation: props => InvitationEmail(props as InvitationEmailProps)
26
+ }
27
+
28
+ async function renderEmailTemplate<T extends EmailTemplateType>(
29
+ templateType: T,
30
+ props: EmailTemplateMap[T]
31
+ ): Promise<{ html: string; text: string }>
32
+
33
+ async function renderEmailTemplate(
34
+ templateType: EmailTemplateType,
35
+ props: EmailTemplateMap[EmailTemplateType]
36
+ ) {
37
+ 'use step'
38
+
39
+ const emailComponent = componentMap[templateType](props)
40
+
41
+ const html = await render(emailComponent)
42
+ const text = toPlainText(html)
43
+ return { html, text }
44
+ }
45
+
46
+ export async function sendWelcomeEmail(to: string, props: WelcomeEmailProps) {
47
+ 'use workflow'
48
+
49
+ const { html, text } = await renderEmailTemplate('welcome', props)
50
+
51
+ return await sendEmail({
52
+ to,
53
+ subject: `Welcome to ${props.companyName}! 🎉`,
54
+ html,
55
+ text
56
+ })
57
+ }
58
+
59
+ export async function sendMagicLinkEmail(
60
+ to: string,
61
+ props: MagicLinkEmailProps
62
+ ) {
63
+ 'use workflow'
64
+
65
+ const { html, text } = await renderEmailTemplate('magicLink', props)
66
+
67
+ return await sendEmail({
68
+ to,
69
+ subject: `Sign in to ${props.companyName}`,
70
+ html,
71
+ text
72
+ })
73
+ }
74
+
75
+ export async function sendInvitationEmail(
76
+ to: string,
77
+ props: InvitationEmailProps
78
+ ) {
79
+ 'use workflow'
80
+
81
+ const { html, text } = await renderEmailTemplate('invitation', props)
82
+
83
+ return await sendEmail({
84
+ to,
85
+ subject: `You've been invited to join ${props.organizationName}`,
86
+ html,
87
+ text
88
+ })
89
+ }
@@ -0,0 +1,27 @@
1
+ export const formatCurrency = (
2
+ currencyCode: string,
3
+ num: number | string,
4
+ precision = 2
5
+ ) => {
6
+ const numCopy = Number(num)
7
+
8
+ switch (currencyCode) {
9
+ case 'INR':
10
+ return new Intl.NumberFormat('en-IN', {
11
+ style: 'currency',
12
+ currency: 'INR',
13
+ signDisplay: 'negative'
14
+ }).format(numCopy)
15
+ case 'USD':
16
+ return new Intl.NumberFormat('en-US', {
17
+ style: 'currency',
18
+ currency: 'USD',
19
+ signDisplay: 'negative'
20
+ }).format(numCopy)
21
+ default:
22
+ console.warn(
23
+ `Unsupported currency: ${currencyCode}. Defaulting to fixed format.`
24
+ )
25
+ return numCopy.toFixed(precision)
26
+ }
27
+ }
@@ -0,0 +1,12 @@
1
+ export const formatDate = (
2
+ date: Date | string | number | null | undefined,
3
+ options?: { short?: boolean; time?: boolean; noYear?: boolean }
4
+ ): string => {
5
+ if (date == null) return '-'
6
+ return new Date(date).toLocaleDateString('en-US', {
7
+ ...(options?.noYear ? {} : { year: 'numeric' }),
8
+ month: options?.short ? 'short' : 'long',
9
+ day: 'numeric',
10
+ ...(options?.time ? { hour: '2-digit', minute: '2-digit' } : {})
11
+ })
12
+ }
@@ -0,0 +1,43 @@
1
+ import nodemailer, { SentMessageInfo } from 'nodemailer'
2
+ import { env } from '~/env'
3
+
4
+ const transporter = nodemailer.createTransport({
5
+ host: env.EMAIL_SERVER_HOST,
6
+ port: parseInt(env.EMAIL_SERVER_PORT),
7
+ secure: parseInt(env.EMAIL_SERVER_PORT) === 465, // Use secure for port 465, false for other ports
8
+ auth: {
9
+ user: env.EMAIL_SERVER_USER,
10
+ pass: env.EMAIL_SERVER_PASSWORD
11
+ }
12
+ })
13
+
14
+ interface SendEmailParams {
15
+ to: string
16
+ subject: string
17
+ text?: string
18
+ html?: string
19
+ }
20
+
21
+ export const sendEmail = async ({
22
+ to,
23
+ subject,
24
+ text,
25
+ html
26
+ }: SendEmailParams): Promise<SentMessageInfo> => {
27
+ 'use step'
28
+
29
+ try {
30
+ const info = await transporter.sendMail({
31
+ from: env.EMAIL_FROM,
32
+ to,
33
+ subject,
34
+ text,
35
+ html
36
+ })
37
+
38
+ return info
39
+ } catch (error) {
40
+ console.error('Error sending email:', error)
41
+ throw new Error('Failed to send email')
42
+ }
43
+ }
@@ -0,0 +1,93 @@
1
+ import 'server-only'
2
+
3
+ import { and, count, desc, eq } from 'drizzle-orm'
4
+ import { cache } from 'react'
5
+ import { db } from '~/db'
6
+ import { membersTable, organizationsTable } from '~/db/schema'
7
+ import type { UserOrganization } from './app-navigation'
8
+ import { getAuthSession } from './auth'
9
+ import { resolveImageUrl } from './storage'
10
+
11
+ const getUserOrganizations = async (): Promise<UserOrganization[]> => {
12
+ const session = await getAuthSession()
13
+ if (!session) return []
14
+
15
+ const orgs = await db
16
+ .select({
17
+ id: organizationsTable.id,
18
+ name: organizationsTable.name,
19
+ slug: organizationsTable.slug,
20
+ logo: organizationsTable.logo,
21
+ role: membersTable.role,
22
+ userId: membersTable.userId,
23
+ memberId: membersTable.id
24
+ })
25
+ .from(membersTable)
26
+ .innerJoin(
27
+ organizationsTable,
28
+ eq(membersTable.organizationId, organizationsTable.id)
29
+ )
30
+ .where(eq(membersTable.userId, session.user.id))
31
+ .orderBy(desc(membersTable.createdAt))
32
+
33
+ return orgs.map(o => ({ ...o, logo: resolveImageUrl(o.logo) }))
34
+ }
35
+
36
+ export const getUserOrganizationsCached = cache(getUserOrganizations)
37
+
38
+ export const getUserMembershipCached = cache(async (organizationId: string) => {
39
+ const session = await getAuthSession()
40
+ if (!session) return null
41
+
42
+ const member = await db.query.membersTable.findFirst({
43
+ where: and(
44
+ eq(membersTable.organizationId, organizationId),
45
+ eq(membersTable.userId, session.user.id)
46
+ )
47
+ })
48
+
49
+ return member ?? null
50
+ })
51
+
52
+ export const getOrganizationMembers = cache(async (organizationId: string) =>
53
+ db.query.membersTable.findMany({
54
+ where: eq(membersTable.organizationId, organizationId),
55
+ with: {
56
+ user: {
57
+ columns: { id: true, name: true, email: true, image: true }
58
+ }
59
+ },
60
+ orderBy: [desc(membersTable.createdAt)]
61
+ })
62
+ )
63
+
64
+ export const getOrganizationMemberCount = cache(
65
+ async (organizationId: string) => {
66
+ const [row] = await db
67
+ .select({ count: count() })
68
+ .from(membersTable)
69
+ .where(eq(membersTable.organizationId, organizationId))
70
+
71
+ return row?.count ?? 0
72
+ }
73
+ )
74
+
75
+ export const assertUserMembership = async (organizationId: string) => {
76
+ const membership = await getUserMembershipCached(organizationId)
77
+ if (!membership) {
78
+ throw new Error('User does not have access to this organization')
79
+ }
80
+ return membership
81
+ }
82
+
83
+ const MANAGER_ROLES = ['owner', 'admin']
84
+
85
+ export const assertUserCanManageOrganization = async (
86
+ organizationId: string
87
+ ) => {
88
+ const membership = await getUserMembershipCached(organizationId)
89
+ if (!membership || !MANAGER_ROLES.includes(membership.role)) {
90
+ throw new Error('User does not have permission to manage this organization')
91
+ }
92
+ return membership
93
+ }
@@ -0,0 +1,38 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { cache } from 'react'
3
+ import { db } from '~/db'
4
+ import { organizationSubscriptionsTable } from '~/db/schema'
5
+ import { PLANS } from './plans'
6
+
7
+ export const getOrganizationPlan = async (organizationId: string) => {
8
+ const sub = await db.query.organizationSubscriptionsTable.findFirst({
9
+ where: eq(organizationSubscriptionsTable.organizationId, organizationId)
10
+ })
11
+
12
+ if (!sub) {
13
+ throw new Error(
14
+ `Organization: ${organizationId} does not have a subscription`
15
+ )
16
+ }
17
+
18
+ const effectivePlan =
19
+ sub.status === 'trialing' ||
20
+ sub.status === 'active' ||
21
+ sub.status === 'past_due'
22
+ ? sub.plan
23
+ : 'free'
24
+
25
+ const plan = PLANS.find(plan => plan.id === effectivePlan)
26
+ if (!plan) {
27
+ throw new Error(
28
+ `Organization: ${organizationId} does not have a valid subscription plan, ${effectivePlan}`
29
+ )
30
+ }
31
+
32
+ return {
33
+ plan,
34
+ sub
35
+ }
36
+ }
37
+
38
+ export const getOrganizationPlanCached = cache(getOrganizationPlan)