create-ej-app 24.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.
- package/cli.js +6 -0
- package/cli.ts +8 -0
- package/package.json +2 -2
- package/template/api/package.json +2 -2
- package/template/nextjs/gitignore +1 -0
- package/template/nextjs/package.json +20 -20
- package/template/nextjs/postcss.config.mjs +3 -1
- package/template/saas-starter/.dockerignore +7 -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/Dockerfile +15 -0
- package/template/saas-starter/README.md +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 +23 -0
- package/template/saas-starter/gitignore +9 -0
- package/template/saas-starter/next-env.d.ts +6 -0
- package/template/saas-starter/next.config.ts +14 -0
- package/template/saas-starter/package.json +92 -0
- package/template/saas-starter/postcss.config.mjs +7 -0
- package/template/saas-starter/prettier.config.mjs +11 -0
- package/template/saas-starter/public/placeholder.svg +1 -0
- package/template/saas-starter/src/actions/uploads.ts +28 -0
- package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -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 +40 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
- package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
- package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
- package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
- package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -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/favicon.ico +0 -0
- package/template/saas-starter/src/app/globals.css +240 -0
- package/template/saas-starter/src/app/layout.tsx +53 -0
- package/template/saas-starter/src/app/not-found.tsx +27 -0
- package/template/saas-starter/src/app/proxy.ts +28 -0
- package/template/saas-starter/src/app/robots.ts +32 -0
- package/template/saas-starter/src/app/sitemap.ts +20 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
- package/template/saas-starter/src/components/data-table/enum.ts +5 -0
- package/template/saas-starter/src/components/data-table/index.tsx +960 -0
- package/template/saas-starter/src/components/data-table/types.ts +75 -0
- package/template/saas-starter/src/components/error.tsx +26 -0
- package/template/saas-starter/src/components/file-upload.tsx +215 -0
- package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
- package/template/saas-starter/src/components/loader.tsx +25 -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 +32 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
- package/template/saas-starter/src/components/screen-size.tsx +40 -0
- package/template/saas-starter/src/components/site-footer.tsx +23 -0
- package/template/saas-starter/src/components/theme-provider.tsx +67 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
- package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
- package/template/saas-starter/src/components/ui/badge.tsx +49 -0
- package/template/saas-starter/src/components/ui/button.tsx +67 -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 +90 -0
- package/template/saas-starter/src/db/common.ts +31 -0
- package/template/saas-starter/src/db/enums.ts +26 -0
- package/template/saas-starter/src/db/index.ts +51 -0
- package/template/saas-starter/src/db/relations.ts +75 -0
- package/template/saas-starter/src/db/schema.ts +191 -0
- package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
- package/template/saas-starter/src/emails/invitation.tsx +56 -0
- package/template/saas-starter/src/emails/magic-link.tsx +49 -0
- package/template/saas-starter/src/emails/test.tsx +32 -0
- package/template/saas-starter/src/emails/welcome.tsx +34 -0
- package/template/saas-starter/src/env.ts +66 -0
- package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
- package/template/saas-starter/src/instrumentation.ts +26 -0
- package/template/saas-starter/src/lib/actions.ts +21 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
- package/template/saas-starter/src/lib/app-navigation.ts +59 -0
- package/template/saas-starter/src/lib/auth-client.ts +29 -0
- package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
- package/template/saas-starter/src/lib/auth.ts +211 -0
- package/template/saas-starter/src/lib/billing.ts +117 -0
- package/template/saas-starter/src/lib/cn.ts +4 -0
- package/template/saas-starter/src/lib/constants.ts +22 -0
- package/template/saas-starter/src/lib/delay.ts +2 -0
- package/template/saas-starter/src/lib/email-service.ts +89 -0
- package/template/saas-starter/src/lib/format-currency.ts +27 -0
- package/template/saas-starter/src/lib/format-date.ts +12 -0
- package/template/saas-starter/src/lib/nodemailer.ts +43 -0
- package/template/saas-starter/src/lib/organization-access.ts +93 -0
- package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
- package/template/saas-starter/src/lib/otel.ts +27 -0
- package/template/saas-starter/src/lib/plans.ts +63 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
- package/template/saas-starter/src/lib/safe-action.ts +67 -0
- package/template/saas-starter/src/lib/siteConfig.ts +4 -0
- package/template/saas-starter/src/lib/storage.ts +181 -0
- package/template/saas-starter/src/lib/stripe.ts +6 -0
- package/template/saas-starter/src/lib/toast-message.ts +15 -0
- package/template/saas-starter/src/lib/validations.ts +12 -0
- package/template/saas-starter/src/scripts/reset-db.ts +22 -0
- package/template/saas-starter/src/scripts/seed.ts +11 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
- package/template/saas-starter/src/scripts/test-mail.ts +35 -0
- 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,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,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)
|