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,27 @@
|
|
|
1
|
+
import { SpanStatusCode, trace } from '@opentelemetry/api'
|
|
2
|
+
|
|
3
|
+
function getSpan() {
|
|
4
|
+
const span = trace.getActiveSpan()
|
|
5
|
+
return span
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function recordWideEvent(attributes: Record<string, any>) {
|
|
9
|
+
const span = getSpan()
|
|
10
|
+
if (!span) return
|
|
11
|
+
|
|
12
|
+
span.setAttributes(attributes)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function recordError(err: unknown) {
|
|
16
|
+
const span = getSpan()
|
|
17
|
+
if (!span) return
|
|
18
|
+
|
|
19
|
+
span.setStatus({
|
|
20
|
+
code: SpanStatusCode.ERROR,
|
|
21
|
+
message: err instanceof Error ? err.message : String(err)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
span.recordException(err instanceof Error ? err : String(err))
|
|
25
|
+
|
|
26
|
+
return span.spanContext().traceId
|
|
27
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { env } from '~/env'
|
|
2
|
+
|
|
3
|
+
export type Plan = {
|
|
4
|
+
id: 'free' | 'pro' | 'ultimate'
|
|
5
|
+
name: string
|
|
6
|
+
maxMembers: number
|
|
7
|
+
stripePrice?: {
|
|
8
|
+
monthly: {
|
|
9
|
+
id: string
|
|
10
|
+
amount: number
|
|
11
|
+
currency: string
|
|
12
|
+
}
|
|
13
|
+
annual: {
|
|
14
|
+
id: string
|
|
15
|
+
amount: number
|
|
16
|
+
currency: string
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
canUseTrial?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const PLANS: Plan[] = [
|
|
23
|
+
{
|
|
24
|
+
id: 'free',
|
|
25
|
+
name: 'Free',
|
|
26
|
+
maxMembers: 1
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'pro',
|
|
30
|
+
name: 'Pro',
|
|
31
|
+
maxMembers: 25,
|
|
32
|
+
stripePrice: {
|
|
33
|
+
monthly: {
|
|
34
|
+
id: env.STRIPE_PRICE_PRO_MONTHLY,
|
|
35
|
+
amount: 2000,
|
|
36
|
+
currency: 'USD'
|
|
37
|
+
},
|
|
38
|
+
annual: {
|
|
39
|
+
id: env.STRIPE_PRICE_PRO_ANNUAL,
|
|
40
|
+
amount: 20000,
|
|
41
|
+
currency: 'USD'
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
canUseTrial: true
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'ultimate',
|
|
48
|
+
name: 'Ultimate',
|
|
49
|
+
maxMembers: 100,
|
|
50
|
+
stripePrice: {
|
|
51
|
+
monthly: {
|
|
52
|
+
id: env.STRIPE_PRICE_ULTIMATE_MONTHLY,
|
|
53
|
+
amount: 10000,
|
|
54
|
+
currency: 'USD'
|
|
55
|
+
},
|
|
56
|
+
annual: {
|
|
57
|
+
id: env.STRIPE_PRICE_ULTIMATE_ANNUAL,
|
|
58
|
+
amount: 100000,
|
|
59
|
+
currency: 'USD'
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useMutation,
|
|
5
|
+
useQuery,
|
|
6
|
+
type UseMutationOptions,
|
|
7
|
+
type UseQueryOptions
|
|
8
|
+
} from '@tanstack/react-query'
|
|
9
|
+
import type { SafeActionResult } from 'next-safe-action'
|
|
10
|
+
|
|
11
|
+
type AnyAction = (
|
|
12
|
+
...args: any[]
|
|
13
|
+
) => Promise<SafeActionResult<any, any, any, any, any>>
|
|
14
|
+
type ActionInput<T extends AnyAction> = Parameters<T>[0]
|
|
15
|
+
type ActionData<T extends AnyAction> = Awaited<ReturnType<T>>['data']
|
|
16
|
+
|
|
17
|
+
class SafeActionError extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
public message: string,
|
|
20
|
+
public validationErrors?: Record<string, string[] | undefined>
|
|
21
|
+
) {
|
|
22
|
+
super(message)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useSafeActionQuery<TAction extends AnyAction>(
|
|
27
|
+
key: string,
|
|
28
|
+
action: TAction,
|
|
29
|
+
input: ActionInput<TAction>,
|
|
30
|
+
options?: Omit<UseQueryOptions<ActionData<TAction>>, 'queryKey' | 'queryFn'>
|
|
31
|
+
) {
|
|
32
|
+
return useQuery({
|
|
33
|
+
queryKey: [key, input],
|
|
34
|
+
queryFn: async () => {
|
|
35
|
+
const result = await action(input)
|
|
36
|
+
|
|
37
|
+
if (result?.serverError) {
|
|
38
|
+
throw new SafeActionError(result.serverError)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (result?.validationErrors) {
|
|
42
|
+
throw new SafeActionError('Validation failed', result.validationErrors)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result?.data
|
|
46
|
+
},
|
|
47
|
+
...options
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useSafeActionMutation<TAction extends AnyAction>(
|
|
52
|
+
action: TAction,
|
|
53
|
+
options?: UseMutationOptions<
|
|
54
|
+
ActionData<TAction>,
|
|
55
|
+
SafeActionError,
|
|
56
|
+
ActionInput<TAction>
|
|
57
|
+
>
|
|
58
|
+
) {
|
|
59
|
+
return useMutation({
|
|
60
|
+
mutationFn: async (input: ActionInput<TAction>) => {
|
|
61
|
+
const result = await action(input)
|
|
62
|
+
|
|
63
|
+
if (result?.serverError) {
|
|
64
|
+
throw new SafeActionError(result.serverError)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (result?.validationErrors) {
|
|
68
|
+
throw new SafeActionError('Validation failed', result.validationErrors)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result?.data
|
|
72
|
+
},
|
|
73
|
+
...options
|
|
74
|
+
})
|
|
75
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createSafeActionClient } from 'next-safe-action'
|
|
2
|
+
import { getAuthSession } from './auth'
|
|
3
|
+
import { recordWideEvent } from './otel'
|
|
4
|
+
|
|
5
|
+
export const actionClient = createSafeActionClient().use(
|
|
6
|
+
async ({ next, ctx }) => {
|
|
7
|
+
const authSession = await getAuthSession()
|
|
8
|
+
|
|
9
|
+
if (authSession)
|
|
10
|
+
recordWideEvent({
|
|
11
|
+
'user.id': authSession.user.id,
|
|
12
|
+
'user.admin': authSession.isAdmin,
|
|
13
|
+
'session.id': authSession.session.id
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
return next({
|
|
17
|
+
ctx: {
|
|
18
|
+
...ctx,
|
|
19
|
+
...authSession
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
export const authActionClient = actionClient.use(async ({ ctx, next }) => {
|
|
26
|
+
if (!ctx?.user || !ctx?.session) {
|
|
27
|
+
throw new Error('Unauthorized')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return next({
|
|
31
|
+
ctx: {
|
|
32
|
+
...ctx,
|
|
33
|
+
user: ctx.user,
|
|
34
|
+
session: ctx.session
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
|
|
40
|
+
if (!ctx.isAdmin) {
|
|
41
|
+
throw new Error('Forbidden')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return next({
|
|
45
|
+
ctx: {
|
|
46
|
+
...ctx,
|
|
47
|
+
user: ctx.user,
|
|
48
|
+
session: ctx.session
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export const superAdminActionClient = adminActionClient.use(
|
|
54
|
+
async ({ ctx, next }) => {
|
|
55
|
+
if (!ctx.isSuperAdmin) {
|
|
56
|
+
throw new Error('Forbidden')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return next({
|
|
60
|
+
ctx: {
|
|
61
|
+
...ctx,
|
|
62
|
+
user: ctx.user,
|
|
63
|
+
session: ctx.session
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeleteObjectCommand,
|
|
3
|
+
PutObjectCommand,
|
|
4
|
+
S3Client
|
|
5
|
+
} from '@aws-sdk/client-s3'
|
|
6
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
7
|
+
import { createId } from '@paralleldrive/cuid2'
|
|
8
|
+
import { and, eq, lt } from 'drizzle-orm'
|
|
9
|
+
import { db } from '~/db'
|
|
10
|
+
import { fileUploadsTable } from '~/db/schema'
|
|
11
|
+
import { env } from '~/env'
|
|
12
|
+
import {
|
|
13
|
+
PRESIGNED_URL_TTL_SECONDS,
|
|
14
|
+
TEMP_CLEANUP_THRESHOLD_MS
|
|
15
|
+
} from './constants'
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
R2 Policy need to be set
|
|
19
|
+
[
|
|
20
|
+
{
|
|
21
|
+
"AllowedOrigins": [
|
|
22
|
+
"http://localhost:3000"
|
|
23
|
+
],
|
|
24
|
+
"AllowedMethods": [
|
|
25
|
+
"PUT"
|
|
26
|
+
],
|
|
27
|
+
"AllowedHeaders": [
|
|
28
|
+
"content-type",
|
|
29
|
+
"content-length"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export function isR2Configured(): boolean {
|
|
36
|
+
return !!(
|
|
37
|
+
env.R2_ACCOUNT_ID &&
|
|
38
|
+
env.R2_ACCESS_KEY_ID &&
|
|
39
|
+
env.R2_SECRET_ACCESS_KEY &&
|
|
40
|
+
env.R2_BUCKET_NAME &&
|
|
41
|
+
env.R2_PUBLIC_URL
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let _r2Client: S3Client | null = null
|
|
46
|
+
|
|
47
|
+
function getR2Client(): S3Client {
|
|
48
|
+
if (_r2Client) return _r2Client
|
|
49
|
+
|
|
50
|
+
const configured = isR2Configured()
|
|
51
|
+
if (!configured) {
|
|
52
|
+
throw new Error('R2 storage is not configured')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_r2Client = new S3Client({
|
|
56
|
+
region: 'auto',
|
|
57
|
+
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
58
|
+
credentials: {
|
|
59
|
+
accessKeyId: env.R2_ACCESS_KEY_ID!,
|
|
60
|
+
secretAccessKey: env.R2_SECRET_ACCESS_KEY!
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return _r2Client
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function deleteFile(key: string): Promise<void> {
|
|
68
|
+
const client = getR2Client()
|
|
69
|
+
await client.send(
|
|
70
|
+
new DeleteObjectCommand({
|
|
71
|
+
Bucket: env.R2_BUCKET_NAME,
|
|
72
|
+
Key: key
|
|
73
|
+
})
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
await db.delete(fileUploadsTable).where(eq(fileUploadsTable.key, key))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function generateUploadUrl(params: {
|
|
80
|
+
filename: string
|
|
81
|
+
mimeType: string
|
|
82
|
+
size: number
|
|
83
|
+
uploadedBy: string
|
|
84
|
+
organizationId?: string | null
|
|
85
|
+
}): Promise<{
|
|
86
|
+
uploadUrl: string
|
|
87
|
+
key: string
|
|
88
|
+
}> {
|
|
89
|
+
const ext = params.filename.split('.').pop()?.toLowerCase() ?? 'bin'
|
|
90
|
+
const folder = params.organizationId
|
|
91
|
+
? `uploads/organizations/${params.organizationId}`
|
|
92
|
+
: `uploads/misc`
|
|
93
|
+
|
|
94
|
+
const fileId = createId()
|
|
95
|
+
const key = `${folder}/${fileId}.${ext}`
|
|
96
|
+
|
|
97
|
+
const client = getR2Client()
|
|
98
|
+
const uploadUrl = await getSignedUrl(
|
|
99
|
+
client,
|
|
100
|
+
new PutObjectCommand({
|
|
101
|
+
Bucket: env.R2_BUCKET_NAME!,
|
|
102
|
+
Key: key,
|
|
103
|
+
ContentType: params.mimeType,
|
|
104
|
+
ContentLength: params.size
|
|
105
|
+
}),
|
|
106
|
+
{ expiresIn: PRESIGNED_URL_TTL_SECONDS }
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
await db.insert(fileUploadsTable).values({
|
|
110
|
+
id: `file_upload_${fileId}`,
|
|
111
|
+
key,
|
|
112
|
+
originalName: params.filename,
|
|
113
|
+
mimeType: params.mimeType,
|
|
114
|
+
size: params.size,
|
|
115
|
+
isTemp: true,
|
|
116
|
+
uploadedBy: params.uploadedBy,
|
|
117
|
+
organizationId: params.organizationId
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
return { uploadUrl, key }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function confirmUpload(
|
|
124
|
+
newKey: string,
|
|
125
|
+
oldKey?: string | null
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
await db
|
|
128
|
+
.update(fileUploadsTable)
|
|
129
|
+
.set({ isTemp: false })
|
|
130
|
+
.where(eq(fileUploadsTable.key, newKey))
|
|
131
|
+
|
|
132
|
+
if (oldKey && isR2Configured()) {
|
|
133
|
+
await deleteFile(oldKey).catch(() => {
|
|
134
|
+
// Non-fatal: old file cleanup failure should not block the save
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isR2Key(value: string | null | undefined): value is string {
|
|
140
|
+
return !!value && !value.startsWith('data:') && !value.startsWith('https://')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resolveImageUrl(
|
|
144
|
+
value: string | null | undefined
|
|
145
|
+
): string | null {
|
|
146
|
+
if (!value) return null
|
|
147
|
+
if (!isR2Key(value)) return value
|
|
148
|
+
if (!isR2Configured()) return null
|
|
149
|
+
return `${env.R2_PUBLIC_URL}/${value}`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function cleanupTempUploads(): Promise<{
|
|
153
|
+
deleted: number
|
|
154
|
+
errors: number
|
|
155
|
+
}> {
|
|
156
|
+
const threshold = new Date(Date.now() - TEMP_CLEANUP_THRESHOLD_MS)
|
|
157
|
+
|
|
158
|
+
const stale = await db
|
|
159
|
+
.select({ id: fileUploadsTable.id, key: fileUploadsTable.key })
|
|
160
|
+
.from(fileUploadsTable)
|
|
161
|
+
.where(
|
|
162
|
+
and(
|
|
163
|
+
eq(fileUploadsTable.isTemp, true),
|
|
164
|
+
lt(fileUploadsTable.createdAt, threshold)
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
let deleted = 0
|
|
169
|
+
let errors = 0
|
|
170
|
+
|
|
171
|
+
for (const file of stale) {
|
|
172
|
+
try {
|
|
173
|
+
await deleteFile(file.key)
|
|
174
|
+
deleted++
|
|
175
|
+
} catch {
|
|
176
|
+
errors++
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { deleted, errors }
|
|
181
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { toast } from 'sonner'
|
|
4
|
+
|
|
5
|
+
export const toastSuccessMessage = (message: string) =>
|
|
6
|
+
toast.success(message, {
|
|
7
|
+
richColors: true,
|
|
8
|
+
position: 'top-center'
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export const toastErrorMessage = (message?: string) =>
|
|
12
|
+
toast.error(message || 'Something went wrong. Please try again later.', {
|
|
13
|
+
richColors: true,
|
|
14
|
+
position: 'top-center'
|
|
15
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import z from 'zod'
|
|
2
|
+
|
|
3
|
+
export const phoneValidation = z
|
|
4
|
+
.string()
|
|
5
|
+
.trim()
|
|
6
|
+
.min(10)
|
|
7
|
+
.max(20)
|
|
8
|
+
.regex(/^[\d+\-().\s]+$/)
|
|
9
|
+
|
|
10
|
+
export const emailValidation = z.email().trim().toLowerCase()
|
|
11
|
+
|
|
12
|
+
export const stringValidation = z.string().trim().min(1).max(255)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
import { db } from '~/db'
|
|
3
|
+
import { env } from '~/env'
|
|
4
|
+
|
|
5
|
+
const resetDb = async () => {
|
|
6
|
+
if (env.APP_ENV === 'production') {
|
|
7
|
+
throw new Error('Cannot reset database in production environment.')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
await db.execute(sql`DROP SCHEMA IF EXISTS public CASCADE;`)
|
|
11
|
+
await db.execute(sql`CREATE SCHEMA public;`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (import.meta.main) {
|
|
15
|
+
try {
|
|
16
|
+
await resetDb()
|
|
17
|
+
process.exit(0)
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error('Error running reset-db:', err)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { writeFile } from 'fs/promises'
|
|
2
|
+
import type Stripe from 'stripe'
|
|
3
|
+
import { env } from '~/env'
|
|
4
|
+
import { PLANS } from '~/lib/plans'
|
|
5
|
+
import { stripeClient } from '~/lib/stripe'
|
|
6
|
+
|
|
7
|
+
const checkStripePriceExists = async (priceId: string) => {
|
|
8
|
+
try {
|
|
9
|
+
await stripeClient.prices.retrieve(priceId)
|
|
10
|
+
return true
|
|
11
|
+
} catch (err) {
|
|
12
|
+
if (
|
|
13
|
+
err &&
|
|
14
|
+
typeof err === 'object' &&
|
|
15
|
+
'code' in err &&
|
|
16
|
+
(err as { code?: string }).code === 'resource_missing'
|
|
17
|
+
) {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const createStripeProduct = async (name: string, planId: string) => {
|
|
25
|
+
const newProduct = await stripeClient.products.create({
|
|
26
|
+
name,
|
|
27
|
+
metadata: { planId }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return newProduct.id
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const createStripePrice = async (
|
|
34
|
+
productId: string,
|
|
35
|
+
planId: string,
|
|
36
|
+
interval: 'month' | 'year',
|
|
37
|
+
amount: number,
|
|
38
|
+
currency: string
|
|
39
|
+
) => {
|
|
40
|
+
const newPrice = await stripeClient.prices.create({
|
|
41
|
+
product: productId,
|
|
42
|
+
currency,
|
|
43
|
+
unit_amount: amount,
|
|
44
|
+
recurring: { interval },
|
|
45
|
+
metadata: { planId }
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return newPrice.id
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type PaidPlanPrices = {
|
|
52
|
+
planId: string
|
|
53
|
+
productId: string
|
|
54
|
+
monthlyPriceId: string
|
|
55
|
+
annualPriceId: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const setupStripePlans = async () => {
|
|
59
|
+
let newEnvString = ''
|
|
60
|
+
const paidPlans: PaidPlanPrices[] = []
|
|
61
|
+
|
|
62
|
+
for (const plan of PLANS) {
|
|
63
|
+
if (!plan.stripePrice) continue
|
|
64
|
+
|
|
65
|
+
const monthlyExists = await checkStripePriceExists(
|
|
66
|
+
plan.stripePrice.monthly.id
|
|
67
|
+
)
|
|
68
|
+
const annualExists = await checkStripePriceExists(
|
|
69
|
+
plan.stripePrice.annual.id
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if ((!monthlyExists && annualExists) || (monthlyExists && !annualExists)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Inconsistent Stripe price setup for plan ${plan.name}. Monthly exists: ${monthlyExists}, Annual exists: ${annualExists}`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (monthlyExists && annualExists) {
|
|
79
|
+
const price = await stripeClient.prices.retrieve(
|
|
80
|
+
plan.stripePrice.monthly.id
|
|
81
|
+
)
|
|
82
|
+
paidPlans.push({
|
|
83
|
+
planId: plan.id,
|
|
84
|
+
productId:
|
|
85
|
+
typeof price.product === 'string' ? price.product : price.product.id,
|
|
86
|
+
monthlyPriceId: plan.stripePrice.monthly.id,
|
|
87
|
+
annualPriceId: plan.stripePrice.annual.id
|
|
88
|
+
})
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const productId = await createStripeProduct(plan.name, plan.id)
|
|
93
|
+
const monthlyPriceId = await createStripePrice(
|
|
94
|
+
productId,
|
|
95
|
+
plan.id,
|
|
96
|
+
'month',
|
|
97
|
+
plan.stripePrice.monthly.amount,
|
|
98
|
+
plan.stripePrice.monthly.currency
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
newEnvString += `\nSTRIPE_PRICE_${plan.id.toUpperCase()}_MONTHLY="${monthlyPriceId}"`
|
|
102
|
+
|
|
103
|
+
const annualPriceId = await createStripePrice(
|
|
104
|
+
productId,
|
|
105
|
+
plan.id,
|
|
106
|
+
'year',
|
|
107
|
+
plan.stripePrice.annual.amount,
|
|
108
|
+
plan.stripePrice.annual.currency
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
newEnvString += `\nSTRIPE_PRICE_${plan.id.toUpperCase()}_ANNUAL="${annualPriceId}"`
|
|
112
|
+
|
|
113
|
+
paidPlans.push({
|
|
114
|
+
planId: plan.id,
|
|
115
|
+
productId,
|
|
116
|
+
monthlyPriceId,
|
|
117
|
+
annualPriceId
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const portalConfigId = await setupPortalConfiguration(paidPlans)
|
|
122
|
+
if (portalConfigId) {
|
|
123
|
+
newEnvString += `\nSTRIPE_PORTAL_CONFIGURATION_ID="${portalConfigId}"`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
newEnvString = newEnvString.trim()
|
|
127
|
+
|
|
128
|
+
if (newEnvString) {
|
|
129
|
+
await writeFile('stripe.txt', newEnvString)
|
|
130
|
+
console.info('Stripe setup complete. New values written to stripe.txt')
|
|
131
|
+
} else {
|
|
132
|
+
console.info('All Stripe plans and portal config already exist.')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const setupPortalConfiguration = async (paidPlans: PaidPlanPrices[]) => {
|
|
137
|
+
if (paidPlans.length === 0) return null
|
|
138
|
+
|
|
139
|
+
const params: Stripe.BillingPortal.ConfigurationCreateParams = {
|
|
140
|
+
features: {
|
|
141
|
+
customer_update: {
|
|
142
|
+
enabled: true,
|
|
143
|
+
allowed_updates: ['address', 'name', 'tax_id']
|
|
144
|
+
},
|
|
145
|
+
invoice_history: { enabled: true },
|
|
146
|
+
payment_method_update: { enabled: true },
|
|
147
|
+
subscription_cancel: {
|
|
148
|
+
enabled: true,
|
|
149
|
+
mode: 'at_period_end',
|
|
150
|
+
cancellation_reason: {
|
|
151
|
+
enabled: true,
|
|
152
|
+
options: [
|
|
153
|
+
'too_expensive',
|
|
154
|
+
'missing_features',
|
|
155
|
+
'switched_service',
|
|
156
|
+
'unused',
|
|
157
|
+
'other'
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
subscription_update: {
|
|
162
|
+
enabled: true,
|
|
163
|
+
default_allowed_updates: ['price'],
|
|
164
|
+
proration_behavior: 'create_prorations',
|
|
165
|
+
products: paidPlans.map(p => ({
|
|
166
|
+
product: p.productId,
|
|
167
|
+
prices: [p.monthlyPriceId, p.annualPriceId]
|
|
168
|
+
}))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const existingId = env.STRIPE_PORTAL_CONFIGURATION_ID
|
|
174
|
+
if (existingId) {
|
|
175
|
+
try {
|
|
176
|
+
await stripeClient.billingPortal.configurations.update(existingId, params)
|
|
177
|
+
return null
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const isMissing =
|
|
180
|
+
err &&
|
|
181
|
+
typeof err === 'object' &&
|
|
182
|
+
'code' in err &&
|
|
183
|
+
(err as { code?: string }).code === 'resource_missing'
|
|
184
|
+
if (!isMissing) throw err
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const created = await stripeClient.billingPortal.configurations.create(params)
|
|
189
|
+
return created.id
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (import.meta.main) {
|
|
193
|
+
try {
|
|
194
|
+
await setupStripePlans()
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error('Error setting up stripe plans:', err)
|
|
197
|
+
} finally {
|
|
198
|
+
process.exit(0)
|
|
199
|
+
}
|
|
200
|
+
}
|