From 43dbf861f68658cabea3b127a2df9de7cfe434f3 Mon Sep 17 00:00:00 2001 From: Tomas Francisco <4301103+tomasfrancisco@users.noreply.github.com> Date: Mon, 13 Jan 2025 17:32:12 +0100 Subject: [PATCH 1/6] feat: draft schedule emails for onboarding --- .../cron/_utils/render-email-template.tsx | 27 + .../app/api/email/cron/_utils/send-email.ts | 41 ++ apps/engine/src/app/api/email/cron/route.ts | 68 +++ apps/engine/src/app/api/email/route.tsx | 25 +- .../src/app/auth/_actions/auth.action.ts | 4 +- .../app/auth/_actions/verify-otp.action.ts | 36 +- .../auth/_utils/schedule-onboarding-emails.ts | 28 ++ apps/engine/src/env/server-env.ts | 1 + apps/engine/src/lib/safe-action.ts | 2 +- apps/engine/vercel.json | 8 + packages/api/package.json | 4 + packages/api/src/app-router.ts | 2 + packages/api/src/index.ts | 8 +- packages/api/src/router/accounts.ts | 18 +- packages/api/src/router/jobs.ts | 78 +++ packages/api/src/rsc.ts | 4 +- packages/api/src/service.tsx | 23 + packages/api/src/trpc.ts | 143 +++++- packages/auth/src/config.ts | 1 + packages/auth/src/server/client.ts | 14 + .../migrations/0001_mean_tinkerer.sql | 118 +++++ .../migrations/meta/0001_snapshot.json | 468 ++++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/package.json | 6 +- packages/database/src/schema.ts | 1 + packages/database/src/schema/accounts.ts | 2 + packages/database/src/schema/jobs.ts | 52 ++ packages/database/src/schema/relations.ts | 9 + packages/email/package.json | 6 +- packages/email/src/config.ts | 13 + packages/email/src/resend.ts | 5 +- .../email/src/templates/onboarding-1d.tsx | 215 ++++++++ .../email/src/templates/onboarding-3d.tsx | 216 ++++++++ .../email/src/templates/welcome-email.tsx | 214 ++++++++ packages/email/tsconfig.json | 2 +- pnpm-lock.yaml | 51 +- pnpm-workspace.yaml | 3 + 37 files changed, 1863 insertions(+), 60 deletions(-) create mode 100644 apps/engine/src/app/api/email/cron/_utils/render-email-template.tsx create mode 100644 apps/engine/src/app/api/email/cron/_utils/send-email.ts create mode 100644 apps/engine/src/app/api/email/cron/route.ts create mode 100644 apps/engine/src/app/auth/_utils/schedule-onboarding-emails.ts create mode 100644 apps/engine/vercel.json create mode 100644 packages/api/src/router/jobs.ts create mode 100644 packages/api/src/service.tsx create mode 100644 packages/database/migrations/0001_mean_tinkerer.sql create mode 100644 packages/database/migrations/meta/0001_snapshot.json create mode 100644 packages/database/src/schema/jobs.ts create mode 100644 packages/email/src/config.ts create mode 100644 packages/email/src/templates/onboarding-1d.tsx create mode 100644 packages/email/src/templates/onboarding-3d.tsx create mode 100644 packages/email/src/templates/welcome-email.tsx diff --git a/apps/engine/src/app/api/email/cron/_utils/render-email-template.tsx b/apps/engine/src/app/api/email/cron/_utils/render-email-template.tsx new file mode 100644 index 00000000..0f6ee6aa --- /dev/null +++ b/apps/engine/src/app/api/email/cron/_utils/render-email-template.tsx @@ -0,0 +1,27 @@ +import { render } from '@ds-project/email/src/render'; +import { Onboarding24hEmail } from '@ds-project/email/src/templates/onboarding-24h'; +import { Onboarding48hEmail } from '@ds-project/email/src/templates/onboarding-48h'; + +export type EmailTemplateKey = 'onboarding-24h' | 'onboarding-48h'; +export type EmailTemplateProps = Record; + +export async function renderEmailTemplate({ + key, + props, +}: { + key: EmailTemplateKey; + props: EmailTemplateProps; +}) { + const template = (() => { + switch (key) { + case 'onboarding-24h': + return ; + case 'onboarding-48h': + return ; + default: + throw new Error(`Unknown template key.`); + } + })(); + + return await render(template); +} diff --git a/apps/engine/src/app/api/email/cron/_utils/send-email.ts b/apps/engine/src/app/api/email/cron/_utils/send-email.ts new file mode 100644 index 00000000..ec3e87cb --- /dev/null +++ b/apps/engine/src/app/api/email/cron/_utils/send-email.ts @@ -0,0 +1,41 @@ +import { api } from '@ds-project/api/service'; +import type { + EmailTemplateKey, + EmailTemplateProps, +} from './render-email-template'; +import { renderEmailTemplate } from './render-email-template'; +import { resend } from '@ds-project/email/src/resend'; + +export async function sendEmail({ + accountId, + subject, + templateKey: key, + templateProps: props, +}: { + accountId: string; + subject: string; + templateKey: EmailTemplateKey; + templateProps: EmailTemplateProps; +}) { + const account = await api.accounts.get({ id: accountId }); + + if (!account) { + // TODO: Handle error + return; + } + + const { error } = await resend.emails.send({ + from: 'DS Pro ', + to: [account.email], + subject, + html: await renderEmailTemplate({ + key, + props, + }), + scheduledAt: 'in 24 hours', + }); + + if (error) { + throw new Error(error.message, { cause: error.name }); + } +} diff --git a/apps/engine/src/app/api/email/cron/route.ts b/apps/engine/src/app/api/email/cron/route.ts new file mode 100644 index 00000000..db8547f2 --- /dev/null +++ b/apps/engine/src/app/api/email/cron/route.ts @@ -0,0 +1,68 @@ +import { serverEnv } from '@/env/server-env'; +import { api } from '@ds-project/api/service'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { Webhook } from 'standardwebhooks'; +import { sendEmail } from './_utils/send-email'; + +/** + * Checks the scheduled emails in the database and sends the emails that are due + */ + +export async function POST(request: NextRequest) { + const wh = new Webhook(serverEnv.SERVICE_HOOK_SECRET); + const payload = await request.text(); + const headers = Object.fromEntries(request.headers); + + // Get due jobs of type email + // Iterate over the due jobs + // Per each job, send the email + // Mark the job as done + + // Verify the request is coming from an authorized source + try { + wh.verify(payload, headers); + } catch (error) { + return NextResponse.json( + { error }, + { + status: 401, + } + ); + } + + const dueEmailJobs = await api.jobs.getDueEmailList(); + + console.log('dueEmailJobs', dueEmailJobs); + + // Run all the possible jobs, don't break if one fails. This way we can process all the jobs + await Promise.allSettled( + dueEmailJobs.map(async (job) => { + if (job.data?.type !== 'email') { + // TODO: Handle error + return; + } + + const account = await api.accounts.get({ id: job.accountId }); + + if (!account) { + // TODO: Handle error + return; + } + + await sendEmail({ + accountId: job.accountId, + subject: job.data.subject, + templateKey: job.data.templateKey, + templateProps: job.data.templateProps, + }); + }) + ); + + return NextResponse.json( + {}, + { + status: 200, + } + ); +} diff --git a/apps/engine/src/app/api/email/route.tsx b/apps/engine/src/app/api/email/route.tsx index fbbdfd6d..85f4ce61 100644 --- a/apps/engine/src/app/api/email/route.tsx +++ b/apps/engine/src/app/api/email/route.tsx @@ -2,13 +2,25 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { serverEnv } from '@/env/server-env'; import { SignUpEmail } from '@ds-project/email/src/templates/sign-up'; -import { Resend } from '@ds-project/email/src/resend'; +import { resend } from '@ds-project/email/src/resend'; import { render } from '@ds-project/email/src/render'; import { config } from '@/config'; import { Webhook } from 'standardwebhooks'; -const resend = new Resend(serverEnv.RESEND_API_KEY); +interface WebhookPayload { + user: { + email: string; + }; + email_data: { + token: string; + }; +} +/** + * Sends a sign up email with an OTP code so the user can authenticate + * @param request + * @returns + */ export async function POST(request: NextRequest) { const wh = new Webhook(serverEnv.SEND_EMAIL_HOOK_SECRET); const payload = await request.text(); @@ -18,14 +30,7 @@ export async function POST(request: NextRequest) { const { user, email_data: { token }, - } = wh.verify(payload, headers) as { - user: { - email: string; - }; - email_data: { - token: string; - }; - }; + } = wh.verify(payload, headers) as WebhookPayload; const html = await render( { - const { error } = await ctx.authClient.auth.verifyOtp({ + const { error, data } = await ctx.authClient.auth.verifyOtp({ email, token, type: 'email', }); if (!error) { - return { - ok: true, - }; + if ( + data.user?.id && + data.user.email_confirmed_at && + new Date(data.user.email_confirmed_at).getTime() < + new Date().getTime() + 1000 * 60 * 1 // 1 minute + ) { + const result = await ctx.authClient + .from('accounts') + .select('id') + .eq('user_id', data.user.id) + .single(); + + if (result.error) { + return { + error: result.error, + ok: false, + }; + } + + await scheduleOnboardingEmails(result.data.id); + return { + ok: true, + }; + } } return { - error: error.message, + error: error?.message ?? 'Error verifying OTP', ok: false, }; }); diff --git a/apps/engine/src/app/auth/_utils/schedule-onboarding-emails.ts b/apps/engine/src/app/auth/_utils/schedule-onboarding-emails.ts new file mode 100644 index 00000000..7270bc46 --- /dev/null +++ b/apps/engine/src/app/auth/_utils/schedule-onboarding-emails.ts @@ -0,0 +1,28 @@ +import { api } from '@ds-project/api/service'; + +export async function scheduleOnboardingEmails(accountId: string) { + await api.jobs.create([ + { + type: 'email', + accountId, + dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours + data: { + type: 'email', + subject: 'Welcome to DS Pro', + templateKey: 'onboarding-24h', + templateProps: {}, + }, + }, + { + type: 'email', + accountId, + dueDate: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(), // 48 hours + data: { + type: 'email', + subject: 'Welcome to DS Pro', + templateKey: 'onboarding-24h', + templateProps: {}, + }, + }, + ]); +} diff --git a/apps/engine/src/env/server-env.ts b/apps/engine/src/env/server-env.ts index 0e9237c6..cce0d514 100644 --- a/apps/engine/src/env/server-env.ts +++ b/apps/engine/src/env/server-env.ts @@ -23,6 +23,7 @@ export const serverEnv = createEnv({ SENTRY_AUTH_TOKEN: z.string().min(1).optional(), RESEND_API_KEY: z.string().min(1), SEND_EMAIL_HOOK_SECRET: z.string().min(1), + SERVICE_HOOK_SECRET: z.string().min(1), // Feature Flags ENABLE_RELEASES_FLAG: z.coerce.boolean(), }, diff --git a/apps/engine/src/lib/safe-action.ts b/apps/engine/src/lib/safe-action.ts index 10321fc8..a1167456 100644 --- a/apps/engine/src/lib/safe-action.ts +++ b/apps/engine/src/lib/safe-action.ts @@ -54,7 +54,7 @@ const actionClient = createSafeActionClient({ return next({ ctx: { ...ctx, authClient } }); }); -export const unprotectedAction = actionClient; +export const publicAction = actionClient; // Auth client defined by extending the base one. // Note that the same initialization options and middleware functions of the base client diff --git a/apps/engine/vercel.json b/apps/engine/vercel.json new file mode 100644 index 00000000..df312eb2 --- /dev/null +++ b/apps/engine/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/email/onboarding", + "schedule": "0 5 * * *" + } + ] +} diff --git a/packages/api/package.json b/packages/api/package.json index ad16e188..3619fa37 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -18,6 +18,10 @@ "types": "./dist/react.d.ts", "default": "./src/react.tsx" }, + "./service": { + "types": "./dist/service.d.ts", + "default": "./src/service.tsx" + }, "./operations": { "types": "./dist/operations.d.ts", "default": "./src/operations/index.ts" diff --git a/packages/api/src/app-router.ts b/packages/api/src/app-router.ts index 9c15cdcd..bbf91190 100644 --- a/packages/api/src/app-router.ts +++ b/packages/api/src/app-router.ts @@ -2,6 +2,7 @@ import { accountsRouter } from './router/accounts'; import { apiKeysRouter } from './router/api-keys'; import { githubRouter } from './router/github'; import { integrationsRouter } from './router/integrations'; +import { jobsRouter } from './router/jobs'; import { projectsRouter } from './router/projects'; import { resourcesRouter } from './router/resources'; import { usersRouter } from './router/users'; @@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({ resources: resourcesRouter, projects: projectsRouter, github: githubRouter, + jobs: jobsRouter, }); // export type definition of API diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index bc75a9ed..20105b28 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -2,7 +2,7 @@ import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; import type { AppRouter } from './app-router'; import { appRouter } from './app-router'; -import { createTRPCContext, createCallerFactory } from './trpc'; +import { createClientTRPCContext, createCallerFactory } from './trpc'; /** * Create a server-side caller for the tRPC API @@ -29,5 +29,9 @@ type RouterInputs = inferRouterInputs; **/ type RouterOutputs = inferRouterOutputs; -export { createTRPCContext, appRouter, createCaller }; +export { + createClientTRPCContext as createTRPCContext, + appRouter, + createCaller, +}; export type { AppRouter, RouterInputs, RouterOutputs }; diff --git a/packages/api/src/router/accounts.ts b/packages/api/src/router/accounts.ts index 7ec684d1..6e81b00c 100644 --- a/packages/api/src/router/accounts.ts +++ b/packages/api/src/router/accounts.ts @@ -1,6 +1,11 @@ import { eq } from '@ds-project/database'; -import { createTRPCRouter, authenticatedProcedure } from '../trpc'; +import { + createTRPCRouter, + authenticatedProcedure, + serviceProcedure, +} from '../trpc'; +import { SelectAccountsSchema } from '@ds-project/database/schema'; export const accountsRouter = createTRPCRouter({ getCurrent: authenticatedProcedure.query(({ ctx }) => { @@ -8,4 +13,15 @@ export const accountsRouter = createTRPCRouter({ where: (accounts) => eq(accounts.id, ctx.account.id), }); }), + get: serviceProcedure + .input(SelectAccountsSchema.pick({ id: true })) + .query(({ ctx, input }) => { + return ctx.database.query.Accounts.findFirst({ + where: (accounts) => eq(accounts.id, input.id), + columns: { + email: true, + id: true, + }, + }); + }), }); diff --git a/packages/api/src/router/jobs.ts b/packages/api/src/router/jobs.ts new file mode 100644 index 00000000..3c579b70 --- /dev/null +++ b/packages/api/src/router/jobs.ts @@ -0,0 +1,78 @@ +import { and, eq, lte } from '@ds-project/database'; +import { createTRPCRouter, serviceProcedure } from '../trpc'; +import { + Jobs, + InsertJobsSchema, + SelectJobsSchema, +} from '@ds-project/database/schema'; +import { z } from 'zod'; + +export const jobsRouter = createTRPCRouter({ + create: serviceProcedure + .input( + z.union([ + InsertJobsSchema.pick({ + accountId: true, + type: true, + dueDate: true, + data: true, + }), + z.array( + InsertJobsSchema.pick({ + accountId: true, + type: true, + dueDate: true, + data: true, + }) + ), + ]) + ) + .mutation(async ({ ctx, input }) => { + if (Array.isArray(input)) { + return ctx.database.transaction(async (tx) => { + for (const job of input) { + await tx + .insert(Jobs) + .values({ + ...job, + state: 'pending', + }) + .onConflictDoNothing(); + } + }); + } + + await ctx.database + .insert(Jobs) + .values({ + ...input, + state: 'pending', + }) + .onConflictDoNothing(); + }), + getDueEmailList: serviceProcedure.query(async ({ ctx, input }) => { + return ctx.database.query.Jobs.findMany({ + where: (jobs) => + and( + eq(jobs.type, 'email'), + eq(jobs.state, 'pending'), + lte(jobs.dueDate, new Date().toISOString()) + ), + columns: { + id: true, + accountId: true, + data: true, + }, + }); + }), + markCompleted: serviceProcedure + .input(SelectJobsSchema.pick({ id: true })) + .mutation(async ({ ctx, input }) => { + return ctx.database + .update(Jobs) + .set({ + state: 'completed', + }) + .where(eq(Jobs.id, input.id)); + }), +}); diff --git a/packages/api/src/rsc.ts b/packages/api/src/rsc.ts index e6d0f98b..6c506154 100644 --- a/packages/api/src/rsc.ts +++ b/packages/api/src/rsc.ts @@ -3,7 +3,7 @@ import { headers } from 'next/headers'; import { createHydrationHelpers } from '@trpc/react-query/rsc'; import { createQueryClient } from './query-client'; -import { createTRPCContext } from './trpc'; +import { createClientTRPCContext } from './trpc'; import type { AppRouter } from '.'; import { createCaller } from '.'; @@ -15,7 +15,7 @@ const createContext = cache(async () => { const _headers = new Headers(headers()); _headers.set('x-trpc-source', 'rsc'); - return createTRPCContext({ + return createClientTRPCContext({ account: null, headers: _headers, }); diff --git a/packages/api/src/service.tsx b/packages/api/src/service.tsx new file mode 100644 index 00000000..8920661b --- /dev/null +++ b/packages/api/src/service.tsx @@ -0,0 +1,23 @@ +import { cache } from 'react'; +import { createHydrationHelpers } from '@trpc/react-query/rsc'; + +import { createQueryClient } from './query-client'; +import { createServiceTRPCContext } from './trpc'; +import type { AppRouter } from '.'; +import { createCaller } from '.'; + +/** + * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when + * handling a tRPC call from a React Server Component. + */ +const createContext = cache(() => { + return createServiceTRPCContext(); +}); + +const getQueryClient = cache(createQueryClient); +const caller = createCaller(createContext); + +export const { trpc: api, HydrateClient } = createHydrationHelpers( + caller, + getQueryClient +); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 96436075..a221e500 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -9,7 +9,10 @@ import { initTRPC, TRPCError } from '@trpc/server'; import SuperJSON from 'superjson'; import { ZodError } from 'zod'; -import { createServerClient } from '@ds-project/auth/server'; +import { + createServerClient, + createServiceClient, +} from '@ds-project/auth/server'; // import type { Session } from '@acme/auth'; // import { auth, validateToken } from '@acme/auth'; @@ -19,10 +22,24 @@ import type { Account } from '@ds-project/database/schema'; import type { Database } from '@ds-project/database'; import { KeyHippo } from 'keyhippo'; +type TRPCContext = { + supabase: ReturnType>; + database: typeof database; +} & ( + | { + userId: string; + authRole: 'api' | 'browser'; + } + | { + authRole: 'service'; + } +); + /** - * 1. CONTEXT + * 1. CLIENT CONTEXT * * This section defines the "contexts" that are available in the backend API. + * It takes in consideration headers, cookies, etc. and returns a context object * * These allow you to access things when processing a request, like the database, the session, etc. * @@ -31,10 +48,10 @@ import { KeyHippo } from 'keyhippo'; * * @see https://trpc.io/docs/server/context */ -export const createTRPCContext = async (opts: { +export const createClientTRPCContext = async (opts: { headers: Headers; account: Account | null; -}) => { +}): Promise => { const supabase = createServerClient(); const keyHippo = new KeyHippo(supabase); const { userId } = await keyHippo.authenticate(opts.headers); @@ -50,22 +67,50 @@ export const createTRPCContext = async (opts: { }; }; +/** + * 1. SERVICE CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * It does not take in consideration headers, cookies, etc. and returns a context object + * + * These allow you to access things when processing a request, like the database, the session, etc. + * + * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each + * wrap this and provides the required context. + * + * @see https://trpc.io/docs/server/context + */ +export const createServiceTRPCContext = (): TRPCContext => { + const supabase = createServiceClient(); + const source = 'service'; + console.log(`>>> tRPC Request from ${source}`); + + return { + supabase, + database, + authRole: 'service', + }; +}; + /** * 2. INITIALIZATION * * This is where the trpc api is initialized, connecting the context and * transformer */ -const t = initTRPC.context().create({ - transformer: SuperJSON, - errorFormatter: ({ shape, error }) => ({ - ...shape, - data: { - ...shape.data, - zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, - }, - }), -}); +const t = initTRPC + .context() + .create({ + transformer: SuperJSON, + errorFormatter: ({ shape, error }) => ({ + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }), + }); /** * Create a server-side caller @@ -119,6 +164,13 @@ const timingMiddleware = t.middleware(async ({ next, path }) => { export const publicProcedure = t.procedure .use(timingMiddleware) .use(({ ctx, next }) => { + if (ctx.authRole !== 'api' && ctx.authRole !== 'browser') { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Procedure context is not initialized.', + }); + } + return ctx.database.transaction(async (tx) => { if (ctx.userId) { await tx.execute( @@ -163,6 +215,13 @@ export const authenticatedProcedure = t.procedure .use(timingMiddleware) .use(({ ctx, next }) => { return ctx.database.transaction(async (tx) => { + if (ctx.authRole !== 'api' && ctx.authRole !== 'browser') { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Procedure context is not initialized.', + }); + } + if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } @@ -181,9 +240,11 @@ export const authenticatedProcedure = t.procedure await tx.execute(sql.raw(`SET ROLE 'authenticated'`)); - const account = ctx.userId + const { userId } = ctx; + + const account = userId ? ((await tx.query.Accounts.findFirst({ - where: (accounts) => eq(accounts.userId, ctx.userId), + where: (accounts) => eq(accounts.userId, userId), with: { accountsToProjects: { columns: { @@ -223,3 +284,53 @@ export const authenticatedProcedure = t.procedure return result; }); }); + +/** + * Service procedure + * + * If you want a query or mutation to ONLY be accessible to service actors, use this. + * It verifies if the service token is valid + * + * @see https://trpc.io/docs/procedures + */ +export const serviceProcedure = t.procedure + .use(timingMiddleware) + .use(({ ctx, next }) => { + if (ctx.authRole !== 'service') { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Procedure context is not initialized.', + }); + } + + return ctx.database.transaction(async (tx) => { + // validate service token? Maybe supabase does this for us 🤷🏻‍♂️ + + await tx.execute( + sql.raw( + `SELECT set_config('request.jwt.claim.role', '${ctx.authRole}', TRUE)` + ) + ); + + await tx.execute(sql.raw(`SET ROLE 'service_role'`)); + + const result = await next({ + ctx: { + ...ctx, + database: tx, + }, + }); + + await tx.execute( + sql.raw(`SELECT set_config('request.jwt.claim.sub', NULL, TRUE)`) + ); + + await tx.execute( + sql.raw(`SELECT set_config('request.jwt.claim.role', NULL, TRUE)`) + ); + + await tx.execute(sql`RESET ROLE`); + + return result; + }); + }); diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 404f5448..81993b32 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -8,6 +8,7 @@ export const env = createEnv({ server: { SUPABASE_URL: z.string().url(), SUPABASE_ANON_KEY: z.string().min(1), + SUPABASE_SERVICE_KEY: z.string().min(1), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/packages/auth/src/server/client.ts b/packages/auth/src/server/client.ts index e63c1ac1..4bbdc710 100644 --- a/packages/auth/src/server/client.ts +++ b/packages/auth/src/server/client.ts @@ -3,6 +3,8 @@ import { cookies } from 'next/headers'; import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies'; import { env } from '../config'; +import { createClient as createJsClient } from '@supabase/supabase-js'; + export function createServerClient(): ReturnType< typeof createClient > { @@ -27,3 +29,15 @@ export function createServerClient(): ReturnType< }, }); } + +export function createServiceClient(): ReturnType< + typeof createClient +> { + return createJsClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_KEY, { + auth: { + autoRefreshToken: false, + persistSession: false, + detectSessionInUrl: false, + }, + }); +} diff --git a/packages/database/migrations/0001_mean_tinkerer.sql b/packages/database/migrations/0001_mean_tinkerer.sql new file mode 100644 index 00000000..a21404c1 --- /dev/null +++ b/packages/database/migrations/0001_mean_tinkerer.sql @@ -0,0 +1,118 @@ +DO $$ BEGIN + CREATE TYPE "public"."integration_type" AS ENUM('github', 'figma'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."job_state" AS ENUM('pending', 'completed', 'failed', 'canceled'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."job_type" AS ENUM('email'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "email" varchar NOT NULL, + CONSTRAINT "accounts_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "integrations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "type" "integration_type" NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "data" jsonb, + CONSTRAINT "integrations_type_project_id_unique" UNIQUE("type","project_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "state" "job_state" NOT NULL, + "type" "job_type" NOT NULL, + "account_id" uuid NOT NULL, + "due_date" timestamp with time zone NOT NULL, + "data" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "projects" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text DEFAULT 'Default Project' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "accounts_to_projects" ( + "account_id" uuid NOT NULL, + "project_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "figma_resources" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "resource_id" uuid NOT NULL, + "name" text NOT NULL, + CONSTRAINT "figma_resources_resource_id_unique" UNIQUE("resource_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "resources" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "project_id" uuid NOT NULL, + "name" text NOT NULL, + "design_tokens" json, + CONSTRAINT "resources_name_project_id_unique" UNIQUE("name","project_id") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "integrations" ADD CONSTRAINT "integrations_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "jobs" ADD CONSTRAINT "jobs_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "accounts_to_projects" ADD CONSTRAINT "accounts_to_projects_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "accounts_to_projects" ADD CONSTRAINT "accounts_to_projects_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "figma_resources" ADD CONSTRAINT "figma_resources_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "resources" ADD CONSTRAINT "resources_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/database/migrations/meta/0001_snapshot.json b/packages/database/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..10a1600e --- /dev/null +++ b/packages/database/migrations/meta/0001_snapshot.json @@ -0,0 +1,468 @@ +{ + "id": "d3c742b3-763f-47b0-9ef3-452508f99f2f", + "prevId": "dccba072-945e-4a90-b839-2bd53362d5db", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_user_id_unique": { + "name": "accounts_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.integrations": { + "name": "integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "integration_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_project_id_projects_id_fk": { + "name": "integrations_project_id_projects_id_fk", + "tableFrom": "integrations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integrations_type_project_id_unique": { + "name": "integrations_type_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "type", + "project_id" + ] + } + } + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "state": { + "name": "state", + "type": "job_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "jobs_account_id_accounts_id_fk": { + "name": "jobs_account_id_accounts_id_fk", + "tableFrom": "jobs", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default Project'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.accounts_to_projects": { + "name": "accounts_to_projects", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_to_projects_account_id_accounts_id_fk": { + "name": "accounts_to_projects_account_id_accounts_id_fk", + "tableFrom": "accounts_to_projects", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accounts_to_projects_project_id_projects_id_fk": { + "name": "accounts_to_projects_project_id_projects_id_fk", + "tableFrom": "accounts_to_projects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.figma_resources": { + "name": "figma_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "figma_resources_resource_id_resources_id_fk": { + "name": "figma_resources_resource_id_resources_id_fk", + "tableFrom": "figma_resources", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "figma_resources_resource_id_unique": { + "name": "figma_resources_resource_id_unique", + "nullsNotDistinct": false, + "columns": [ + "resource_id" + ] + } + } + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "design_tokens": { + "name": "design_tokens", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "resources_project_id_projects_id_fk": { + "name": "resources_project_id_projects_id_fk", + "tableFrom": "resources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resources_name_project_id_unique": { + "name": "resources_name_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "name", + "project_id" + ] + } + } + } + }, + "enums": { + "public.integration_type": { + "name": "integration_type", + "schema": "public", + "values": [ + "github", + "figma" + ] + }, + "public.job_state": { + "name": "job_state", + "schema": "public", + "values": [ + "pending", + "completed", + "failed", + "canceled" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 11b40776..3ba845d1 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1727085939563, "tag": "0000_windy_glorian", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1736521272427, + "tag": "0001_mean_tinkerer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/package.json b/packages/database/package.json index 6d76c413..f2308c30 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -39,9 +39,9 @@ }, "prettier": "@ds-project/prettier", "dependencies": { - "@next/env": "^14.2.13", - "@t3-oss/env-core": "^0.11.1", - "@t3-oss/env-nextjs": "^0.11.1", + "@next/env": "catalog:", + "@t3-oss/env-core": "catalog:", + "@t3-oss/env-nextjs": "catalog:", "@terrazzo/parser": "^0.1.0", "@terrazzo/token-tools": "catalog:", "drizzle-kit": "^0.24.2", diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index c55de2ad..1dbfac2c 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -1,5 +1,6 @@ export * from './schema/accounts'; export * from './schema/integrations'; +export * from './schema/jobs'; export * from './schema/projects'; export * from './schema/relations'; export * from './schema/resources'; diff --git a/packages/database/src/schema/accounts.ts b/packages/database/src/schema/accounts.ts index 1014ebbb..ce1d87f1 100644 --- a/packages/database/src/schema/accounts.ts +++ b/packages/database/src/schema/accounts.ts @@ -1,5 +1,6 @@ import { pgTable, uuid, varchar } from 'drizzle-orm/pg-core'; import { usersTable } from './_auth/users'; +import { createSelectSchema } from 'drizzle-zod'; export const Accounts = pgTable('accounts', { id: uuid('id').defaultRandom().primaryKey().notNull(), @@ -15,3 +16,4 @@ export const Accounts = pgTable('accounts', { }); export type Account = typeof Accounts.$inferSelect; +export const SelectAccountsSchema = createSelectSchema(Accounts); diff --git a/packages/database/src/schema/jobs.ts b/packages/database/src/schema/jobs.ts new file mode 100644 index 00000000..b2a7c87d --- /dev/null +++ b/packages/database/src/schema/jobs.ts @@ -0,0 +1,52 @@ +import { jsonb, pgEnum, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { Accounts } from './accounts'; +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { z } from 'zod'; + +export const jobTypeEnum = pgEnum('job_type', ['email']); + +export const jobType = z.enum(jobTypeEnum.enumValues); +export const emailDataSchema = z.object({ + type: z.literal(jobType.Enum.email), + templateKey: z.enum(['onboarding-24h', 'onboarding-48h']), + templateProps: z.record(z.unknown()), + subject: z.string().min(1), +}); + +type JobData = z.infer; + +export const jobStateEnum = pgEnum('job_state', [ + 'pending', + 'completed', + 'failed', + 'canceled', +]); + +export const Jobs = pgTable('jobs', { + id: uuid('id').defaultRandom().primaryKey().notNull(), + state: jobStateEnum('state').notNull(), + type: jobTypeEnum('type').notNull(), + accountId: uuid('account_id') + .references(() => Accounts.id, { onDelete: 'cascade' }) + .notNull(), + dueDate: timestamp('due_date', { + withTimezone: true, + mode: 'string', + }).notNull(), + data: jsonb('data').$type(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdate(() => new Date().toISOString()), +}); + +export type InsertJobs = typeof Jobs.$inferInsert; +export type SelectJobs = typeof Jobs.$inferSelect; + +export const InsertJobsSchema = createInsertSchema(Jobs, { + data: () => emailDataSchema, +}); +export const SelectJobsSchema = createSelectSchema(Jobs); diff --git a/packages/database/src/schema/relations.ts b/packages/database/src/schema/relations.ts index f239a898..915cb688 100644 --- a/packages/database/src/schema/relations.ts +++ b/packages/database/src/schema/relations.ts @@ -5,6 +5,7 @@ import { Accounts } from './accounts'; import { Resources } from './resources'; import { Integrations } from './integrations'; import { usersTable } from './_auth/users'; +import { Jobs } from './jobs'; export const ProjectsRelations = relations(Projects, ({ many }) => ({ accountsToProjects: many(AccountsToProjects), @@ -14,6 +15,7 @@ export const ProjectsRelations = relations(Projects, ({ many }) => ({ export const AccountsRelations = relations(Accounts, ({ many, one }) => ({ accountsToProjects: many(AccountsToProjects), + jobs: many(Jobs), user: one(usersTable), })); @@ -45,3 +47,10 @@ export const IntegrationsRelations = relations(Integrations, ({ one }) => ({ references: [Projects.id], }), })); + +export const JobsRelations = relations(Jobs, ({ one }) => ({ + account: one(Accounts, { + fields: [Jobs.accountId], + references: [Accounts.id], + }), +})); diff --git a/packages/email/package.json b/packages/email/package.json index 1462f196..70b55a08 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -13,15 +13,19 @@ }, "prettier": "@ds-project/prettier", "dependencies": { + "@next/env": "catalog:", "@react-email/components": "0.0.24", "@react-email/render": "^1.0.1", + "@t3-oss/env-core": "catalog:", "react": "catalog:", - "resend": "^4.0.0" + "resend": "^4.0.0", + "zod": "^3.24.1" }, "devDependencies": { "@ds-project/eslint": "workspace:*", "@ds-project/prettier": "workspace:*", "@ds-project/typescript": "workspace:*", + "@types/node": "catalog:", "@types/react": "catalog:", "eslint": "catalog:", "react-email": "3.0.1" diff --git a/packages/email/src/config.ts b/packages/email/src/config.ts new file mode 100644 index 00000000..f420b6f1 --- /dev/null +++ b/packages/email/src/config.ts @@ -0,0 +1,13 @@ +import { createEnv } from '@t3-oss/env-core'; +import { z } from 'zod'; +import { loadEnvConfig } from '@next/env'; + +loadEnvConfig(process.cwd()); + +export const env = createEnv({ + server: { + RESEND_API_KEY: z.string().min(1), + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, +}); diff --git a/packages/email/src/resend.ts b/packages/email/src/resend.ts index de227eec..063d3246 100644 --- a/packages/email/src/resend.ts +++ b/packages/email/src/resend.ts @@ -1 +1,4 @@ -export * from 'resend'; +import { Resend } from 'resend'; +import { env } from './config'; + +export const resend = new Resend(env.RESEND_API_KEY); diff --git a/packages/email/src/templates/onboarding-1d.tsx b/packages/email/src/templates/onboarding-1d.tsx new file mode 100644 index 00000000..a9ae0f83 --- /dev/null +++ b/packages/email/src/templates/onboarding-1d.tsx @@ -0,0 +1,215 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Section, + Text, + Button, + Hr, +} from '@react-email/components'; + +interface Onboarding24EmailProps { + username?: string; + staticPathUrl?: string; +} + +export const Onboarding24Email = ({ + username = 'there', + staticPathUrl = '/static', +}: Onboarding24EmailProps) => ( + + + + + DS Pro + Ready to sync? + + Let's Get Your Design Tokens Flowing + + +
+ Hi {username}, + + I noticed you signed up for DS Pro yesterday. Have you had a chance + to set up your first Figma to GitHub sync? If not, I'd love to help + you get started and share some exciting updates about what's coming + next. + +
+ +
+ +
+ Quick Start Your Token Sync + + In just 5 minutes, you can set up automated synchronization between + your Figma design tokens and GitHub repository. Our quick start + guide will show you exactly how. + + +
+ +
+ +
+ Coming Soon: More Integrations + + We're working on direct NPM registry publishing and one-click design + system generation. Want to learn more? Let's hop on a quick call + where I can share our roadmap and get your input. + + +
+ +
+ +
+ + Having trouble with the setup? Just hit reply - I personally respond + to every email and I'm here to help you get your tokens syncing + smoothly. + +
+ + + Here to help you sync, +
+ Tomás +
+ Creator of DS Pro +
+
+ + Sent by DS Pro. Contact us at{' '} + tomas@getds.pro. + + + +); + +Onboarding24Email.PreviewProps = { + username: 'John', +} as Onboarding24EmailProps; + +export default Onboarding24Email; + +const main = { + backgroundColor: '#ffffff', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', +}; + +const container = { + backgroundColor: '#ffffff', + border: '1px solid #eee', + borderRadius: '5px', + boxShadow: '0 5px 10px rgba(20,50,70,.2)', + marginTop: '20px', + maxWidth: '360px', + margin: '0 auto', + padding: '0px 16px 40px', + overflow: 'hidden', +}; + +const logo = { + margin: '0 auto', +}; + +const tertiary = { + color: '#0a85ea', + fontSize: '11px', + fontWeight: 700, + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + height: '16px', + letterSpacing: '0', + lineHeight: '16px', + margin: '16px 8px 8px 8px', + textTransform: 'uppercase' as const, + textAlign: 'center' as const, +}; + +const secondary = { + color: '#000', + display: 'inline-block', + fontFamily: 'HelveticaNeue-Medium,Helvetica,Arial,sans-serif', + fontSize: '20px', + fontWeight: 500, + lineHeight: '24px', + marginBottom: '0', + marginTop: '0', + textAlign: 'center' as const, +}; + +const contentSection = { + margin: '24px 0', +}; + +const sectionTitle = { + color: '#000', + fontSize: '15px', + fontWeight: 700, + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + letterSpacing: '0', + lineHeight: '23px', + margin: '8px 0', +}; + +const paragraph = { + color: '#444', + fontSize: '15px', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + letterSpacing: '0', + lineHeight: '23px', + margin: '8px 0', +}; + +const button = { + backgroundColor: '#0a85ea', + borderRadius: '4px', + color: '#fff', + display: 'inline-block', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + fontSize: '14px', + fontWeight: 600, + lineHeight: '40px', + textDecoration: 'none', + textAlign: 'center' as const, + width: '100%', + margin: '16px 0', +}; + +const divider = { + borderColor: '#eee', + margin: '20px 0', +}; + +const signature = { + ...paragraph, + marginTop: '32px', + textAlign: 'center' as const, +}; + +const footer = { + color: '#000', + fontSize: '12px', + fontWeight: 800, + letterSpacing: '0', + lineHeight: '23px', + margin: '0', + marginTop: '20px', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + textAlign: 'center' as const, + textTransform: 'uppercase' as const, +}; diff --git a/packages/email/src/templates/onboarding-3d.tsx b/packages/email/src/templates/onboarding-3d.tsx new file mode 100644 index 00000000..e7db1c66 --- /dev/null +++ b/packages/email/src/templates/onboarding-3d.tsx @@ -0,0 +1,216 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Section, + Text, + Button, + Hr, +} from '@react-email/components'; + +interface Onboarding3DEmailProps { + username?: string; + staticPathUrl?: string; +} + +export const Onboarding3DEmail = ({ + username = 'there', + staticPathUrl = '/static', +}: Onboarding3DEmailProps) => ( + + + + + DS Pro + The Future of Design Tokens + Help Shape What's Coming Next + +
+ Hi {username}, + + As one of our early users, I wanted to give you a sneak peek into + what we're building next at DS Pro. Your feedback on these upcoming + features would be incredibly valuable. + +
+ +
+ +
+ Coming Soon: Direct NPM Publishing + + Soon you'll be able to publish your design tokens directly to the + NPM registry. Want early access? Join our Discord community to be + the first to know when it's ready. + + +
+ +
+ +
+ Preview: One-Click Design Systems + + We're working on generating complete design systems with a single + click. Book a call to see an early demo and share your thoughts on + what would make this feature perfect for your workflow. + + +
+ +
+ +
+ Need Help With Token Sync? + + Still getting set up with the Figma to GitHub sync? Let's hop on a + quick call to get you up and running, and I can show you some tips + for managing your design tokens effectively. + + +
+ + + Building the future together, +
+ Tomás +
+ Creator of DS Pro +
+
+ + Sent by DS Pro. Contact us at{' '} + tomas@getds.pro. + + + +); + +Onboarding3DEmail.PreviewProps = { + username: 'John', +} as Onboarding3DEmailProps; + +export default Onboarding3DEmail; + +const main = { + backgroundColor: '#ffffff', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', +}; + +const container = { + backgroundColor: '#ffffff', + border: '1px solid #eee', + borderRadius: '5px', + boxShadow: '0 5px 10px rgba(20,50,70,.2)', + marginTop: '20px', + maxWidth: '360px', + margin: '0 auto', + padding: '0px 16px 40px', + overflow: 'hidden', +}; + +const logo = { + margin: '0 auto', +}; + +const tertiary = { + color: '#0a85ea', + fontSize: '11px', + fontWeight: 700, + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + height: '16px', + letterSpacing: '0', + lineHeight: '16px', + margin: '16px 8px 8px 8px', + textTransform: 'uppercase' as const, + textAlign: 'center' as const, +}; + +const secondary = { + color: '#000', + display: 'inline-block', + fontFamily: 'HelveticaNeue-Medium,Helvetica,Arial,sans-serif', + fontSize: '20px', + fontWeight: 500, + lineHeight: '24px', + marginBottom: '0', + marginTop: '0', + textAlign: 'center' as const, +}; + +const contentSection = { + margin: '24px 0', +}; + +const sectionTitle = { + color: '#000', + fontSize: '15px', + fontWeight: 700, + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + letterSpacing: '0', + lineHeight: '23px', + margin: '8px 0', +}; + +const paragraph = { + color: '#444', + fontSize: '15px', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + letterSpacing: '0', + lineHeight: '23px', + margin: '8px 0', +}; + +const button = { + backgroundColor: '#0a85ea', + borderRadius: '4px', + color: '#fff', + display: 'inline-block', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + fontSize: '14px', + fontWeight: 600, + lineHeight: '40px', + textDecoration: 'none', + textAlign: 'center' as const, + width: '100%', + margin: '16px 0', +}; + +const divider = { + borderColor: '#eee', + margin: '20px 0', +}; + +const signature = { + ...paragraph, + marginTop: '32px', + textAlign: 'center' as const, +}; + +const footer = { + color: '#000', + fontSize: '12px', + fontWeight: 800, + letterSpacing: '0', + lineHeight: '23px', + margin: '0', + marginTop: '20px', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + textAlign: 'center' as const, + textTransform: 'uppercase' as const, +}; diff --git a/packages/email/src/templates/welcome-email.tsx b/packages/email/src/templates/welcome-email.tsx new file mode 100644 index 00000000..2a845ce8 --- /dev/null +++ b/packages/email/src/templates/welcome-email.tsx @@ -0,0 +1,214 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Section, + Text, + Button, + Hr, +} from '@react-email/components'; + +interface WelcomeEmailProps { + username?: string; + staticPathUrl?: string; +} + +export const WelcomeEmail = ({ + username = 'there', + staticPathUrl = '/static', +}: WelcomeEmailProps) => ( + + + + + DS Pro + Welcome to DS Pro + + Seamless Design Token Synchronization Awaits + + +
+ Hi {username}, + + Thank you for joining DS Pro! I'm excited to help you streamline + your design token workflow between Figma and GitHub. Let's get you + started with the essentials. + +
+ +
+ +
+ Set Up Your First Sync + + Watch our quick setup guide to connect your Figma design tokens with + your GitHub repository. It's easier than you think! + + +
+ +
+ +
+ Book a Personal Demo + + Want to ensure you're getting the most out of token synchronization? + Let's hop on a quick call where I can show you our best practices + and upcoming features like NPM registry publishing and one-click + design system generation. + + +
+ +
+ +
+ + Have questions about setting up your token sync? Just reply to this + email - I personally respond to every message and I'm here to help + you succeed. + +
+ + + Looking forward to helping you sync, +
+ Tomás +
+ Creator of DS Pro +
+
+ + Sent by DS Pro. Contact us at{' '} + tomas@getds.pro. + + + +); + +WelcomeEmail.PreviewProps = { + username: 'John', +} as WelcomeEmailProps; + +export default WelcomeEmail; + +const main = { + backgroundColor: '#ffffff', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', +}; + +const container = { + backgroundColor: '#ffffff', + border: '1px solid #eee', + borderRadius: '5px', + boxShadow: '0 5px 10px rgba(20,50,70,.2)', + marginTop: '20px', + maxWidth: '360px', + margin: '0 auto', + padding: '0px 16px 40px', + overflow: 'hidden', +}; + +const logo = { + margin: '0 auto', +}; + +const tertiary = { + color: '#0a85ea', + fontSize: '11px', + fontWeight: 700, + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + height: '16px', + letterSpacing: '0', + lineHeight: '16px', + margin: '16px 8px 8px 8px', + textTransform: 'uppercase' as const, + textAlign: 'center' as const, +}; + +const secondary = { + color: '#000', + display: 'inline-block', + fontFamily: 'HelveticaNeue-Medium,Helvetica,Arial,sans-serif', + fontSize: '20px', + fontWeight: 500, + lineHeight: '24px', + marginBottom: '0', + marginTop: '0', + textAlign: 'center' as const, +}; + +const contentSection = { + margin: '24px 0', +}; + +const sectionTitle = { + color: '#000', + fontSize: '15px', + fontWeight: 700, + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + letterSpacing: '0', + lineHeight: '23px', + margin: '8px 0', +}; + +const paragraph = { + color: '#444', + fontSize: '15px', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + letterSpacing: '0', + lineHeight: '23px', + margin: '8px 0', +}; + +const button = { + backgroundColor: '#0a85ea', + borderRadius: '4px', + color: '#fff', + display: 'inline-block', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + fontSize: '14px', + fontWeight: 600, + lineHeight: '40px', + textDecoration: 'none', + textAlign: 'center' as const, + width: '100%', + margin: '16px 0', +}; + +const divider = { + borderColor: '#eee', + margin: '20px 0', +}; + +const signature = { + ...paragraph, + marginTop: '32px', + textAlign: 'center' as const, +}; + +const footer = { + color: '#000', + fontSize: '12px', + fontWeight: 800, + letterSpacing: '0', + lineHeight: '23px', + margin: '0', + marginTop: '20px', + fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + textAlign: 'center' as const, + textTransform: 'uppercase' as const, +}; diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json index e0852463..f245d485 100644 --- a/packages/email/tsconfig.json +++ b/packages/email/tsconfig.json @@ -13,7 +13,7 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", - "typeRoots": ["./dist/index.d.ts"], + // "typeRoots": ["./dist/index.d.ts"], "declarationMap": true, "inlineSources": false, "preserveWatchOutput": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11f71aaa..0e0be579 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,15 @@ catalogs: '@figma/widget-typings': specifier: ^1.9.1 version: 1.9.1 + '@next/env': + specifier: ^14.2.13 + version: 14.2.13 + '@t3-oss/env-core': + specifier: ^0.11.1 + version: 0.11.1 + '@t3-oss/env-nextjs': + specifier: ^0.11.1 + version: 0.11.1 '@terrazzo/token-tools': specifier: ^0.1.3 version: 0.1.3 @@ -663,13 +672,13 @@ importers: packages/database: dependencies: '@next/env': - specifier: ^14.2.13 + specifier: 'catalog:' version: 14.2.13 '@t3-oss/env-core': - specifier: ^0.11.1 + specifier: 'catalog:' version: 0.11.1(typescript@5.5.4)(zod@3.23.8) '@t3-oss/env-nextjs': - specifier: ^0.11.1 + specifier: 'catalog:' version: 0.11.1(typescript@5.5.4)(zod@3.23.8) '@terrazzo/parser': specifier: ^0.1.0 @@ -720,18 +729,27 @@ importers: packages/email: dependencies: + '@next/env': + specifier: 'catalog:' + version: 14.2.13 '@react-email/components': specifier: 0.0.24 version: 0.0.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-email/render': specifier: ^1.0.1 version: 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@t3-oss/env-core': + specifier: 'catalog:' + version: 0.11.1(typescript@5.5.4)(zod@3.24.1) react: specifier: 'catalog:' version: 18.3.1 resend: specifier: ^4.0.0 version: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zod: + specifier: ^3.24.1 + version: 3.24.1 devDependencies: '@ds-project/eslint': specifier: workspace:* @@ -742,6 +760,9 @@ importers: '@ds-project/typescript': specifier: workspace:* version: link:../../tools/typescript + '@types/node': + specifier: 'catalog:' + version: 22.7.8 '@types/react': specifier: 'catalog:' version: 18.3.3 @@ -4257,9 +4278,6 @@ packages: '@types/node@18.19.39': resolution: {integrity: sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==} - '@types/node@22.5.5': - resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==} - '@types/node@22.7.8': resolution: {integrity: sha512-a922jJy31vqR5sk+kAdIENJjHblqcZ4RmERviFsER4WJcEONqxKcjNOlk0q7OUfrF5sddT+vng070cdfMlrPLg==} @@ -8873,6 +8891,9 @@ packages: zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -11891,6 +11912,12 @@ snapshots: optionalDependencies: typescript: 5.5.4 + '@t3-oss/env-core@0.11.1(typescript@5.5.4)(zod@3.24.1)': + dependencies: + zod: 3.24.1 + optionalDependencies: + typescript: 5.5.4 + '@t3-oss/env-nextjs@0.11.1(typescript@5.5.4)(zod@3.23.8)': dependencies: '@t3-oss/env-core': 0.11.1(typescript@5.5.4)(zod@3.23.8) @@ -12048,7 +12075,7 @@ snapshots: '@types/cors@2.8.17': dependencies: - '@types/node': 22.5.5 + '@types/node': 22.7.8 '@types/culori@2.1.1': {} @@ -12092,7 +12119,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 22.5.5 + '@types/node': 22.7.8 '@types/hast@3.0.4': dependencies: @@ -12132,10 +12159,6 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@22.5.5': - dependencies: - undici-types: 6.19.8 - '@types/node@22.7.8': dependencies: undici-types: 6.19.8 @@ -13601,7 +13624,7 @@ snapshots: dependencies: '@types/cookie': 0.4.1 '@types/cors': 2.8.17 - '@types/node': 22.5.5 + '@types/node': 22.7.8 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.4.2 @@ -17695,4 +17718,6 @@ snapshots: zod@3.23.8: {} + zod@3.24.1: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c88625c6..d8ffdbda 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,9 @@ packages: - tools/* catalog: + "@next/env": ^14.2.13 + "@t3-oss/env-core": ^0.11.1 + "@t3-oss/env-nextjs": ^0.11.1 "@figma/plugin-typings": ^1.99.0 "@figma/widget-typings": ^1.9.1 "@terrazzo/token-tools": ^0.1.3 From 9096dd9c487cd77564d3937dbce0a57b9ffc18a3 Mon Sep 17 00:00:00 2001 From: Tomas Francisco <4301103+tomasfrancisco@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:10:25 +0100 Subject: [PATCH 2/6] feat: add onboarding messaging flow --- .../cron/_utils/render-email-template.tsx | 27 -------- .../app/api/email/cron/_utils/send-email.ts | 41 ------------ apps/engine/src/app/api/email/cron/route.ts | 36 +++++------ apps/engine/src/app/api/email/route.tsx | 29 ++++----- .../app/auth/_actions/verify-otp.action.ts | 16 ++++- .../auth/_utils/schedule-onboarding-emails.ts | 23 ++++--- apps/engine/vercel.json | 4 +- packages/api/src/router/jobs.ts | 2 +- packages/database/src/schema/jobs.ts | 63 +++++++++++++++++-- packages/email/package.json | 8 +++ packages/email/src/index.ts | 1 + ...onboarding-1d.tsx => onboarding-1-day.tsx} | 20 +++--- ...nboarding-3d.tsx => onboarding-3-days.tsx} | 16 ++--- .../templates/{sign-up.tsx => verify-otp.tsx} | 12 ++-- .../{welcome-email.tsx => welcome.tsx} | 4 +- packages/email/src/utils/index.ts | 1 + .../email/src/utils/render-email-template.tsx | 27 ++++++++ packages/email/src/utils/send-email.ts | 44 +++++++++++++ packages/email/tsconfig.json | 2 - pnpm-lock.yaml | 6 ++ 20 files changed, 233 insertions(+), 149 deletions(-) delete mode 100644 apps/engine/src/app/api/email/cron/_utils/render-email-template.tsx delete mode 100644 apps/engine/src/app/api/email/cron/_utils/send-email.ts create mode 100644 packages/email/src/index.ts rename packages/email/src/templates/{onboarding-1d.tsx => onboarding-1-day.tsx} (91%) rename packages/email/src/templates/{onboarding-3d.tsx => onboarding-3-days.tsx} (93%) rename packages/email/src/templates/{sign-up.tsx => verify-otp.tsx} (94%) rename packages/email/src/templates/{welcome-email.tsx => welcome.tsx} (97%) create mode 100644 packages/email/src/utils/index.ts create mode 100644 packages/email/src/utils/render-email-template.tsx create mode 100644 packages/email/src/utils/send-email.ts diff --git a/apps/engine/src/app/api/email/cron/_utils/render-email-template.tsx b/apps/engine/src/app/api/email/cron/_utils/render-email-template.tsx deleted file mode 100644 index 0f6ee6aa..00000000 --- a/apps/engine/src/app/api/email/cron/_utils/render-email-template.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { render } from '@ds-project/email/src/render'; -import { Onboarding24hEmail } from '@ds-project/email/src/templates/onboarding-24h'; -import { Onboarding48hEmail } from '@ds-project/email/src/templates/onboarding-48h'; - -export type EmailTemplateKey = 'onboarding-24h' | 'onboarding-48h'; -export type EmailTemplateProps = Record; - -export async function renderEmailTemplate({ - key, - props, -}: { - key: EmailTemplateKey; - props: EmailTemplateProps; -}) { - const template = (() => { - switch (key) { - case 'onboarding-24h': - return ; - case 'onboarding-48h': - return ; - default: - throw new Error(`Unknown template key.`); - } - })(); - - return await render(template); -} diff --git a/apps/engine/src/app/api/email/cron/_utils/send-email.ts b/apps/engine/src/app/api/email/cron/_utils/send-email.ts deleted file mode 100644 index ec3e87cb..00000000 --- a/apps/engine/src/app/api/email/cron/_utils/send-email.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { api } from '@ds-project/api/service'; -import type { - EmailTemplateKey, - EmailTemplateProps, -} from './render-email-template'; -import { renderEmailTemplate } from './render-email-template'; -import { resend } from '@ds-project/email/src/resend'; - -export async function sendEmail({ - accountId, - subject, - templateKey: key, - templateProps: props, -}: { - accountId: string; - subject: string; - templateKey: EmailTemplateKey; - templateProps: EmailTemplateProps; -}) { - const account = await api.accounts.get({ id: accountId }); - - if (!account) { - // TODO: Handle error - return; - } - - const { error } = await resend.emails.send({ - from: 'DS Pro ', - to: [account.email], - subject, - html: await renderEmailTemplate({ - key, - props, - }), - scheduledAt: 'in 24 hours', - }); - - if (error) { - throw new Error(error.message, { cause: error.name }); - } -} diff --git a/apps/engine/src/app/api/email/cron/route.ts b/apps/engine/src/app/api/email/cron/route.ts index db8547f2..0b3ccda9 100644 --- a/apps/engine/src/app/api/email/cron/route.ts +++ b/apps/engine/src/app/api/email/cron/route.ts @@ -1,9 +1,9 @@ import { serverEnv } from '@/env/server-env'; import { api } from '@ds-project/api/service'; +import { sendEmail } from '@ds-project/email'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { Webhook } from 'standardwebhooks'; -import { sendEmail } from './_utils/send-email'; /** * Checks the scheduled emails in the database and sends the emails that are due @@ -14,11 +14,6 @@ export async function POST(request: NextRequest) { const payload = await request.text(); const headers = Object.fromEntries(request.headers); - // Get due jobs of type email - // Iterate over the due jobs - // Per each job, send the email - // Mark the job as done - // Verify the request is coming from an authorized source try { wh.verify(payload, headers); @@ -33,29 +28,32 @@ export async function POST(request: NextRequest) { const dueEmailJobs = await api.jobs.getDueEmailList(); - console.log('dueEmailJobs', dueEmailJobs); - + console.log(`👀 ${dueEmailJobs.length} due email jobs found.`); // Run all the possible jobs, don't break if one fails. This way we can process all the jobs await Promise.allSettled( - dueEmailJobs.map(async (job) => { + dueEmailJobs.map(async (job, jobIndex) => { + console.log( + `⚙️ (${jobIndex + 1}/${dueEmailJobs.length}) Processing job ${job.id}.` + ); + // Only process jobs of type email if (job.data?.type !== 'email') { - // TODO: Handle error - return; - } - - const account = await api.accounts.get({ id: job.accountId }); - - if (!account) { - // TODO: Handle error + console.log( + `⏭️ (${jobIndex + 1}/${dueEmailJobs.length}) Skipped job ${job.id}.` + ); return; } await sendEmail({ accountId: job.accountId, subject: job.data.subject, - templateKey: job.data.templateKey, - templateProps: job.data.templateProps, + template: job.data.template, }); + + await api.jobs.markCompleted({ id: job.id }); + + console.log( + `📧 (${jobIndex + 1}/${dueEmailJobs.length}) Email job ${job.id} processed successfully.` + ); }) ); diff --git a/apps/engine/src/app/api/email/route.tsx b/apps/engine/src/app/api/email/route.tsx index 85f4ce61..e0b1069a 100644 --- a/apps/engine/src/app/api/email/route.tsx +++ b/apps/engine/src/app/api/email/route.tsx @@ -1,11 +1,9 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { serverEnv } from '@/env/server-env'; -import { SignUpEmail } from '@ds-project/email/src/templates/sign-up'; -import { resend } from '@ds-project/email/src/resend'; -import { render } from '@ds-project/email/src/render'; import { config } from '@/config'; import { Webhook } from 'standardwebhooks'; +import { sendEmail } from '@ds-project/email'; interface WebhookPayload { user: { @@ -32,23 +30,18 @@ export async function POST(request: NextRequest) { email_data: { token }, } = wh.verify(payload, headers) as WebhookPayload; - const html = await render( - - ); - - const { error } = await resend.emails.send({ - from: 'DS Pro ', - to: [user.email], + // Send OTP email to the user + await sendEmail({ + email: user.email, subject: 'DS Pro - Confirmation Code', - html, + template: { + key: 'verify-otp', + props: { + otpCode: token, + staticPathUrl: `${config.pageUrl}/static/email`, + }, + }, }); - - if (error) { - throw new Error(error.message, { cause: error.name }); - } } catch (error) { return NextResponse.json( { error }, diff --git a/apps/engine/src/app/auth/_actions/verify-otp.action.ts b/apps/engine/src/app/auth/_actions/verify-otp.action.ts index 4eb6dcce..566f482a 100644 --- a/apps/engine/src/app/auth/_actions/verify-otp.action.ts +++ b/apps/engine/src/app/auth/_actions/verify-otp.action.ts @@ -4,6 +4,8 @@ import { z } from 'zod'; import { publicAction } from '@/lib/safe-action'; import { zfd } from 'zod-form-data'; import { scheduleOnboardingEmails } from '../_utils/schedule-onboarding-emails'; +import { sendEmail } from '@ds-project/email'; +import { config } from '@/config'; export const verifyOtpAction = publicAction .metadata({ actionName: 'verifyOtpAction' }) @@ -39,11 +41,23 @@ export const verifyOtpAction = publicAction if (result.error) { return { - error: result.error, + error: result.error.message, ok: false, }; } + await sendEmail({ + accountId: result.data.id, + subject: 'Welcome to DS Pro', + template: { + key: 'welcome', + props: { + staticPathUrl: `${config.pageUrl}/static/email`, + }, + }, + scheduledAt: 'in 5 minutes', + }); + await scheduleOnboardingEmails(result.data.id); return { ok: true, diff --git a/apps/engine/src/app/auth/_utils/schedule-onboarding-emails.ts b/apps/engine/src/app/auth/_utils/schedule-onboarding-emails.ts index 7270bc46..9ee49692 100644 --- a/apps/engine/src/app/auth/_utils/schedule-onboarding-emails.ts +++ b/apps/engine/src/app/auth/_utils/schedule-onboarding-emails.ts @@ -1,3 +1,4 @@ +import { config } from '@/config'; import { api } from '@ds-project/api/service'; export async function scheduleOnboardingEmails(accountId: string) { @@ -8,20 +9,28 @@ export async function scheduleOnboardingEmails(accountId: string) { dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours data: { type: 'email', - subject: 'Welcome to DS Pro', - templateKey: 'onboarding-24h', - templateProps: {}, + subject: 'DS Pro - Ready to sync?', + template: { + key: 'onboarding-1d', + props: { + staticPathUrl: `${config.pageUrl}/static/email`, + }, + }, }, }, { type: 'email', accountId, - dueDate: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(), // 48 hours + dueDate: new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString(), // 72 hours data: { type: 'email', - subject: 'Welcome to DS Pro', - templateKey: 'onboarding-24h', - templateProps: {}, + subject: 'DS Pro - The Future of Design Tokens', + template: { + key: 'onboarding-3d', + props: { + staticPathUrl: `${config.pageUrl}/static/email`, + }, + }, }, }, ]); diff --git a/apps/engine/vercel.json b/apps/engine/vercel.json index df312eb2..346c91f2 100644 --- a/apps/engine/vercel.json +++ b/apps/engine/vercel.json @@ -1,8 +1,8 @@ { "crons": [ { - "path": "/api/email/onboarding", - "schedule": "0 5 * * *" + "path": "/api/email/cron", + "schedule": "10 10 * * *" } ] } diff --git a/packages/api/src/router/jobs.ts b/packages/api/src/router/jobs.ts index 3c579b70..02f25990 100644 --- a/packages/api/src/router/jobs.ts +++ b/packages/api/src/router/jobs.ts @@ -50,7 +50,7 @@ export const jobsRouter = createTRPCRouter({ }) .onConflictDoNothing(); }), - getDueEmailList: serviceProcedure.query(async ({ ctx, input }) => { + getDueEmailList: serviceProcedure.query(async ({ ctx }) => { return ctx.database.query.Jobs.findMany({ where: (jobs) => and( diff --git a/packages/database/src/schema/jobs.ts b/packages/database/src/schema/jobs.ts index b2a7c87d..7fd8cc0c 100644 --- a/packages/database/src/schema/jobs.ts +++ b/packages/database/src/schema/jobs.ts @@ -6,13 +6,66 @@ import { z } from 'zod'; export const jobTypeEnum = pgEnum('job_type', ['email']); export const jobType = z.enum(jobTypeEnum.enumValues); -export const emailDataSchema = z.object({ - type: z.literal(jobType.Enum.email), - templateKey: z.enum(['onboarding-24h', 'onboarding-48h']), - templateProps: z.record(z.unknown()), - subject: z.string().min(1), + +export const emailTemplateKeySchema = z.enum([ + 'verify-otp', + 'welcome', + 'onboarding-1d', + 'onboarding-3d', +]); + +export type EmailTemplateKey = z.infer; + +const verifyOtpEmailTemplatePropsSchema = z.object({ + otpCode: z.string(), + staticPathUrl: z.string(), }); +export const emailTemplatePropsSchema = z.object({ + staticPathUrl: z.string(), +}); + +export type EmailTemplateType = + | { + key: 'verify-otp'; + props: z.infer; + } + | { + key: 'welcome'; + props: z.infer; + } + | { + key: 'onboarding-1d'; + props: z.infer; + } + | { + key: 'onboarding-3d'; + props: z.infer; + }; + +export const emailDataSchema = z.union([ + z.object({ + type: z.literal(jobType.Enum.email), + template: z.object({ + key: z.literal(emailTemplateKeySchema.Enum['verify-otp']), + props: verifyOtpEmailTemplatePropsSchema, + }), + subject: z.string().min(1), + }), + z.object({ + type: z.literal(jobType.Enum.email), + template: z.object({ + key: z.enum([ + emailTemplateKeySchema.Enum.welcome, + emailTemplateKeySchema.Enum['onboarding-1d'], + emailTemplateKeySchema.Enum['onboarding-3d'], + ]), + props: emailTemplatePropsSchema, + }), + subject: z.string().min(1), + }), +]); + type JobData = z.infer; export const jobStateEnum = pgEnum('job_state', [ diff --git a/packages/email/package.json b/packages/email/package.json index 70b55a08..6f657e68 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -5,6 +5,12 @@ "license": "ISC", "author": "", "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./src/index.ts" + } + }, "scripts": { "dev": "email dev --dir ./src/templates", "format": "prettier --check . --ignore-path ../../.gitignore --ignore-path ../../.prettierignore", @@ -13,6 +19,8 @@ }, "prettier": "@ds-project/prettier", "dependencies": { + "@ds-project/api": "workspace:*", + "@ds-project/database": "workspace:*", "@next/env": "catalog:", "@react-email/components": "0.0.24", "@react-email/render": "^1.0.1", diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts new file mode 100644 index 00000000..847e7e8f --- /dev/null +++ b/packages/email/src/index.ts @@ -0,0 +1 @@ +export * from './utils/send-email'; diff --git a/packages/email/src/templates/onboarding-1d.tsx b/packages/email/src/templates/onboarding-1-day.tsx similarity index 91% rename from packages/email/src/templates/onboarding-1d.tsx rename to packages/email/src/templates/onboarding-1-day.tsx index a9ae0f83..c4f380bf 100644 --- a/packages/email/src/templates/onboarding-1d.tsx +++ b/packages/email/src/templates/onboarding-1-day.tsx @@ -12,15 +12,15 @@ import { Hr, } from '@react-email/components'; -interface Onboarding24EmailProps { +interface Onboarding1DayEmailProps { username?: string; staticPathUrl?: string; } -export const Onboarding24Email = ({ +export const Onboarding1DayEmail = ({ username = 'there', staticPathUrl = '/static', -}: Onboarding24EmailProps) => ( +}: Onboarding1DayEmailProps) => ( @@ -51,12 +51,12 @@ export const Onboarding24Email = ({
Quick Start Your Token Sync - In just 5 minutes, you can set up automated synchronization between + In just 3 minutes, you can set up automated synchronization between your Figma design tokens and GitHub repository. Our quick start guide will show you exactly how. -
@@ -69,7 +69,7 @@ export const Onboarding24Email = ({ system generation. Want to learn more? Let's hop on a quick call where I can share our roadmap and get your input. - @@ -100,11 +100,11 @@ export const Onboarding24Email = ({ ); -Onboarding24Email.PreviewProps = { +Onboarding1DayEmail.PreviewProps = { username: 'John', -} as Onboarding24EmailProps; +} as Onboarding1DayEmailProps; -export default Onboarding24Email; +export default Onboarding1DayEmail; const main = { backgroundColor: '#ffffff', diff --git a/packages/email/src/templates/onboarding-3d.tsx b/packages/email/src/templates/onboarding-3-days.tsx similarity index 93% rename from packages/email/src/templates/onboarding-3d.tsx rename to packages/email/src/templates/onboarding-3-days.tsx index e7db1c66..f9462ded 100644 --- a/packages/email/src/templates/onboarding-3d.tsx +++ b/packages/email/src/templates/onboarding-3-days.tsx @@ -12,15 +12,15 @@ import { Hr, } from '@react-email/components'; -interface Onboarding3DEmailProps { +interface Onboarding3DaysEmailProps { username?: string; staticPathUrl?: string; } -export const Onboarding3DEmail = ({ +export const Onboarding3DaysEmail = ({ username = 'there', staticPathUrl = '/static', -}: Onboarding3DEmailProps) => ( +}: Onboarding3DaysEmailProps) => ( @@ -52,7 +52,7 @@ export const Onboarding3DEmail = ({ NPM registry. Want early access? Join our Discord community to be the first to know when it's ready. - @@ -66,7 +66,7 @@ export const Onboarding3DEmail = ({ click. Book a call to see an early demo and share your thoughts on what would make this feature perfect for your workflow. - @@ -101,11 +101,11 @@ export const Onboarding3DEmail = ({ ); -Onboarding3DEmail.PreviewProps = { +Onboarding3DaysEmail.PreviewProps = { username: 'John', -} as Onboarding3DEmailProps; +} as Onboarding3DaysEmailProps; -export default Onboarding3DEmail; +export default Onboarding3DaysEmail; const main = { backgroundColor: '#ffffff', diff --git a/packages/email/src/templates/sign-up.tsx b/packages/email/src/templates/verify-otp.tsx similarity index 94% rename from packages/email/src/templates/sign-up.tsx rename to packages/email/src/templates/verify-otp.tsx index 809e4bed..657142dc 100644 --- a/packages/email/src/templates/sign-up.tsx +++ b/packages/email/src/templates/verify-otp.tsx @@ -10,15 +10,15 @@ import { Text, } from '@react-email/components'; -interface SignUpEmailProps { +interface VerifyOTPEmailProps { otpCode?: string; staticPathUrl?: string; } -export const SignUpEmail = ({ +export const VerifyOTPEmail = ({ otpCode, staticPathUrl = '/static', -}: SignUpEmailProps) => ( +}: VerifyOTPEmailProps) => ( @@ -50,11 +50,11 @@ export const SignUpEmail = ({ ); -SignUpEmail.PreviewProps = { +VerifyOTPEmail.PreviewProps = { otpCode: '000000', -} as SignUpEmailProps; +} as VerifyOTPEmailProps; -export default SignUpEmail; +export default VerifyOTPEmail; const main = { backgroundColor: '#ffffff', diff --git a/packages/email/src/templates/welcome-email.tsx b/packages/email/src/templates/welcome.tsx similarity index 97% rename from packages/email/src/templates/welcome-email.tsx rename to packages/email/src/templates/welcome.tsx index 2a845ce8..196b2eb9 100644 --- a/packages/email/src/templates/welcome-email.tsx +++ b/packages/email/src/templates/welcome.tsx @@ -53,7 +53,7 @@ export const WelcomeEmail = ({ Watch our quick setup guide to connect your Figma design tokens with your GitHub repository. It's easier than you think! - @@ -68,7 +68,7 @@ export const WelcomeEmail = ({ and upcoming features like NPM registry publishing and one-click design system generation. - diff --git a/packages/email/src/utils/index.ts b/packages/email/src/utils/index.ts new file mode 100644 index 00000000..ecff4880 --- /dev/null +++ b/packages/email/src/utils/index.ts @@ -0,0 +1 @@ +export * from './send-email'; diff --git a/packages/email/src/utils/render-email-template.tsx b/packages/email/src/utils/render-email-template.tsx new file mode 100644 index 00000000..0bb44e0d --- /dev/null +++ b/packages/email/src/utils/render-email-template.tsx @@ -0,0 +1,27 @@ +import type { EmailTemplateType } from '@ds-project/database/schema'; + +import { render } from '@react-email/render'; + +import { Onboarding1DayEmail } from '../templates/onboarding-1-day'; +import VerifyOTPEmail from '../templates/verify-otp'; +import WelcomeEmail from '../templates/welcome'; +import Onboarding3DaysEmail from '../templates/onboarding-3-days'; + +export async function renderEmailTemplate(templateProps: EmailTemplateType) { + const template = (() => { + switch (templateProps.key) { + case 'verify-otp': + return ; + case 'welcome': + return ; + case 'onboarding-1d': + return ; + case 'onboarding-3d': + return ; + default: + throw new Error(`Unknown template key.`); + } + })(); + + return await render(template); +} diff --git a/packages/email/src/utils/send-email.ts b/packages/email/src/utils/send-email.ts new file mode 100644 index 00000000..639e126b --- /dev/null +++ b/packages/email/src/utils/send-email.ts @@ -0,0 +1,44 @@ +import { api } from '@ds-project/api/service'; +import type { EmailTemplateType } from '@ds-project/database/schema'; +import { renderEmailTemplate } from './render-email-template'; +import { resend } from '../resend'; +import type { CreateEmailOptions } from 'resend'; + +export async function sendEmail({ + accountId, + email, + subject, + template, + scheduledAt, +}: { + accountId?: string; + email?: string; + subject: string; + template: EmailTemplateType; + scheduledAt?: CreateEmailOptions['scheduledAt']; +}) { + let emailTo = email; + + if (!emailTo && accountId) { + const account = await api.accounts.get({ id: accountId }); + + emailTo = account?.email; + } + + if (!emailTo) { + throw new Error('No email provided.'); + } + + const { error } = await resend.emails.send({ + from: 'DS Pro ', + replyTo: 'Tomas @ DS Pro ', + to: [emailTo], + subject, + html: await renderEmailTemplate(template), + scheduledAt, + }); + + if (error) { + throw new Error(error.message, { cause: error.name }); + } +} diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json index f245d485..8dbf78dd 100644 --- a/packages/email/tsconfig.json +++ b/packages/email/tsconfig.json @@ -23,8 +23,6 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - - "baseUrl": "." }, "include": ["src"], "exclude": ["dist", "node_modules"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0be579..4696a909 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -729,6 +729,12 @@ importers: packages/email: dependencies: + '@ds-project/api': + specifier: workspace:* + version: link:../api + '@ds-project/database': + specifier: workspace:* + version: link:../database '@next/env': specifier: 'catalog:' version: 14.2.13 From 4990ffbc603946f1ad72036bfc39ab0b44dde84d Mon Sep 17 00:00:00 2001 From: Tomas Francisco <4301103+tomasfrancisco@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:17:11 +0100 Subject: [PATCH 3/6] chore: remove unused props --- packages/email/src/templates/onboarding-1-day.tsx | 4 +--- packages/email/src/templates/onboarding-3-days.tsx | 4 +--- packages/email/src/templates/welcome.tsx | 4 +--- packages/email/tsconfig.json | 2 +- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/email/src/templates/onboarding-1-day.tsx b/packages/email/src/templates/onboarding-1-day.tsx index c4f380bf..e4af58a6 100644 --- a/packages/email/src/templates/onboarding-1-day.tsx +++ b/packages/email/src/templates/onboarding-1-day.tsx @@ -13,12 +13,10 @@ import { } from '@react-email/components'; interface Onboarding1DayEmailProps { - username?: string; staticPathUrl?: string; } export const Onboarding1DayEmail = ({ - username = 'there', staticPathUrl = '/static', }: Onboarding1DayEmailProps) => ( @@ -37,7 +35,7 @@ export const Onboarding1DayEmail = ({
- Hi {username}, + Hi there, I noticed you signed up for DS Pro yesterday. Have you had a chance to set up your first Figma to GitHub sync? If not, I'd love to help diff --git a/packages/email/src/templates/onboarding-3-days.tsx b/packages/email/src/templates/onboarding-3-days.tsx index f9462ded..86adfefb 100644 --- a/packages/email/src/templates/onboarding-3-days.tsx +++ b/packages/email/src/templates/onboarding-3-days.tsx @@ -13,12 +13,10 @@ import { } from '@react-email/components'; interface Onboarding3DaysEmailProps { - username?: string; staticPathUrl?: string; } export const Onboarding3DaysEmail = ({ - username = 'there', staticPathUrl = '/static', }: Onboarding3DaysEmailProps) => ( @@ -35,7 +33,7 @@ export const Onboarding3DaysEmail = ({ Help Shape What's Coming Next
- Hi {username}, + Hi there, As one of our early users, I wanted to give you a sneak peek into what we're building next at DS Pro. Your feedback on these upcoming diff --git a/packages/email/src/templates/welcome.tsx b/packages/email/src/templates/welcome.tsx index 196b2eb9..7c5c5355 100644 --- a/packages/email/src/templates/welcome.tsx +++ b/packages/email/src/templates/welcome.tsx @@ -13,12 +13,10 @@ import { } from '@react-email/components'; interface WelcomeEmailProps { - username?: string; staticPathUrl?: string; } export const WelcomeEmail = ({ - username = 'there', staticPathUrl = '/static', }: WelcomeEmailProps) => ( @@ -37,7 +35,7 @@ export const WelcomeEmail = ({
- Hi {username}, + Hi there, Thank you for joining DS Pro! I'm excited to help you streamline your design token workflow between Figma and GitHub. Let's get you diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json index 8dbf78dd..2eaa0867 100644 --- a/packages/email/tsconfig.json +++ b/packages/email/tsconfig.json @@ -13,7 +13,7 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", - // "typeRoots": ["./dist/index.d.ts"], + "typeRoots": ["./dist/index.d.ts"], "declarationMap": true, "inlineSources": false, "preserveWatchOutput": true, From 58cca7268a0b63a0b141705448bd516f0af57459 Mon Sep 17 00:00:00 2001 From: Tomas Francisco <4301103+tomasfrancisco@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:29:01 +0100 Subject: [PATCH 4/6] fix: add missing env var to turbo config --- turbo.json | 1 + 1 file changed, 1 insertion(+) diff --git a/turbo.json b/turbo.json index 21daaadb..6fe113b3 100644 --- a/turbo.json +++ b/turbo.json @@ -17,6 +17,7 @@ "POSTGRES_URL", "RESEND_API_KEY", "SEND_EMAIL_HOOK_SECRET", + "SERVICE_HOOK_SECRET", "SUPABASE_ANON_KEY", "SUPABASE_URL", "VITE_HOST_URL" From 6ea4440185707bfeb71c6cc9d4fe9e020cef4998 Mon Sep 17 00:00:00 2001 From: Tomas Francisco <4301103+tomasfrancisco@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:36:06 +0100 Subject: [PATCH 5/6] fix: missing supabsae service role key --- packages/auth/src/config.ts | 2 +- packages/auth/src/server/client.ts | 18 +++++++++++------- turbo.json | 1 + 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 81993b32..9b318b41 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -8,7 +8,7 @@ export const env = createEnv({ server: { SUPABASE_URL: z.string().url(), SUPABASE_ANON_KEY: z.string().min(1), - SUPABASE_SERVICE_KEY: z.string().min(1), + SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/packages/auth/src/server/client.ts b/packages/auth/src/server/client.ts index 4bbdc710..b9a13a64 100644 --- a/packages/auth/src/server/client.ts +++ b/packages/auth/src/server/client.ts @@ -33,11 +33,15 @@ export function createServerClient(): ReturnType< export function createServiceClient(): ReturnType< typeof createClient > { - return createJsClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_KEY, { - auth: { - autoRefreshToken: false, - persistSession: false, - detectSessionInUrl: false, - }, - }); + return createJsClient( + env.SUPABASE_URL, + env.SUPABASE_SERVICE_ROLE_KEY, + { + auth: { + autoRefreshToken: false, + persistSession: false, + detectSessionInUrl: false, + }, + } + ); } diff --git a/turbo.json b/turbo.json index 6fe113b3..168af2cd 100644 --- a/turbo.json +++ b/turbo.json @@ -18,6 +18,7 @@ "RESEND_API_KEY", "SEND_EMAIL_HOOK_SECRET", "SERVICE_HOOK_SECRET", + "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_ANON_KEY", "SUPABASE_URL", "VITE_HOST_URL" From 84274451ca7c836cbf701e27bf01b28634134759 Mon Sep 17 00:00:00 2001 From: Tomas Francisco <4301103+tomasfrancisco@users.noreply.github.com> Date: Wed, 15 Jan 2025 12:47:52 +0100 Subject: [PATCH 6/6] chore: format and lint --- apps/engine/package.json | 2 +- packages/components/package.json | 2 +- packages/database/package.json | 2 +- packages/email/tsconfig.json | 2 +- packages/figma-utilities/package.json | 2 +- packages/figma-widget/package.json | 2 +- pnpm-lock.yaml | 77 ++++++++++++--------------- pnpm-workspace.yaml | 2 +- 8 files changed, 40 insertions(+), 51 deletions(-) diff --git a/apps/engine/package.json b/apps/engine/package.json index 5c355035..b02bb066 100644 --- a/apps/engine/package.json +++ b/apps/engine/package.json @@ -65,7 +65,7 @@ "standardwebhooks": "^1.0.0", "superjson": "^2.2.1", "tailwind-merge": "^2.4.0", - "zod": "^3.23.8", + "zod": "catalog:", "zod-form-data": "^2.0.2" }, "devDependencies": { diff --git a/packages/components/package.json b/packages/components/package.json index d7b7d814..9572af0f 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -64,7 +64,7 @@ "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "tailwindcss-fluid-type": "^2.0.6", - "zod": "^3.23.8" + "zod": "catalog:" }, "devDependencies": { "@ds-project/eslint": "workspace:*", diff --git a/packages/database/package.json b/packages/database/package.json index f2308c30..74016fc3 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -49,7 +49,7 @@ "drizzle-zod": "^0.5.1", "postgres": "^3.4.4", "server-only": "^0.0.1", - "zod": "^3.23.8" + "zod": "catalog:" }, "devDependencies": { "@ds-project/eslint": "workspace:*", diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json index 2eaa0867..0ba43f7c 100644 --- a/packages/email/tsconfig.json +++ b/packages/email/tsconfig.json @@ -22,7 +22,7 @@ "declaration": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, + "noFallthroughCasesInSwitch": true }, "include": ["src"], "exclude": ["dist", "node_modules"] diff --git a/packages/figma-utilities/package.json b/packages/figma-utilities/package.json index 4f8ccb50..c544e48f 100644 --- a/packages/figma-utilities/package.json +++ b/packages/figma-utilities/package.json @@ -22,7 +22,7 @@ "@create-figma-plugin/utilities": "^3.2.0", "@terrazzo/token-tools": "catalog:", "object-hash": "^3.0.0", - "zod": "^3.23.8" + "zod": "catalog:" }, "devDependencies": { "@ds-project/eslint": "workspace:*", diff --git a/packages/figma-widget/package.json b/packages/figma-widget/package.json index f0958f57..56ec95fe 100644 --- a/packages/figma-widget/package.json +++ b/packages/figma-widget/package.json @@ -34,7 +34,7 @@ "react": "catalog:", "react-dom": "catalog:", "superjson": "^2.2.1", - "zod": "^3.23.8" + "zod": "catalog:" }, "devDependencies": { "@ds-project/api": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4696a909..6ba35aa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,8 +73,8 @@ catalogs: specifier: ^2.0.2 version: 2.0.2 zod: - specifier: ^3.23.8 - version: 3.23.8 + specifier: ^3.24.1 + version: 3.24.1 overrides: '@trpc/client': 11.0.0-rc.482 @@ -152,10 +152,10 @@ importers: version: 2.45.0 '@t3-oss/env-core': specifier: ^0.11.1 - version: 0.11.1(typescript@5.5.4)(zod@3.23.8) + version: 0.11.1(typescript@5.5.4)(zod@3.24.1) '@t3-oss/env-nextjs': specifier: ^0.11.1 - version: 0.11.1(typescript@5.5.4)(zod@3.23.8) + version: 0.11.1(typescript@5.5.4)(zod@3.24.1) '@tanstack/react-query': specifier: ^5.51.24 version: 5.51.24(react@18.3.1) @@ -188,7 +188,7 @@ importers: version: 0.32.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@18.3.3)(postgres@3.4.4)(react@18.3.1) drizzle-zod: specifier: ^0.5.1 - version: 0.5.1(drizzle-orm@0.32.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@18.3.3)(postgres@3.4.4)(react@18.3.1))(zod@3.23.8) + version: 0.5.1(drizzle-orm@0.32.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@18.3.3)(postgres@3.4.4)(react@18.3.1))(zod@3.24.1) framer-motion: specifier: ^11.3.21 version: 11.3.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -203,7 +203,7 @@ importers: version: 14.2.12(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) next-safe-action: specifier: ^7.8.1 - version: 7.8.1(next@14.2.12(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8) + version: 7.8.1(next@14.2.12(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.24.1) postgres: specifier: ^3.4.4 version: 3.4.4 @@ -247,11 +247,11 @@ importers: specifier: ^2.4.0 version: 2.4.0 zod: - specifier: ^3.23.8 - version: 3.23.8 + specifier: 'catalog:' + version: 3.24.1 zod-form-data: specifier: ^2.0.2 - version: 2.0.2(zod@3.23.8) + version: 2.0.2(zod@3.24.1) devDependencies: '@ds-project/eslint': specifier: workspace:* @@ -409,7 +409,7 @@ importers: version: 5.3.3(@types/node@22.7.8)(sass@1.77.8)(terser@5.33.0) zod: specifier: 'catalog:' - version: 3.23.8 + version: 3.24.1 packages/api: dependencies: @@ -451,7 +451,7 @@ importers: version: 2.2.1 zod: specifier: 'catalog:' - version: 3.23.8 + version: 3.24.1 devDependencies: '@ds-project/eslint': specifier: workspace:* @@ -488,13 +488,13 @@ importers: version: 2.45.0 '@t3-oss/env-core': specifier: ^0.11.1 - version: 0.11.1(typescript@5.5.4)(zod@3.23.8) + version: 0.11.1(typescript@5.5.4)(zod@3.24.1) next: specifier: 'catalog:' version: 14.2.12(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) zod: specifier: 'catalog:' - version: 3.23.8 + version: 3.24.1 devDependencies: '@ds-project/eslint': specifier: workspace:* @@ -617,8 +617,8 @@ importers: specifier: ^2.0.6 version: 2.0.6(tailwindcss@3.4.10) zod: - specifier: ^3.23.8 - version: 3.23.8 + specifier: 'catalog:' + version: 3.24.1 devDependencies: '@ds-project/eslint': specifier: workspace:* @@ -676,10 +676,10 @@ importers: version: 14.2.13 '@t3-oss/env-core': specifier: 'catalog:' - version: 0.11.1(typescript@5.5.4)(zod@3.23.8) + version: 0.11.1(typescript@5.5.4)(zod@3.24.1) '@t3-oss/env-nextjs': specifier: 'catalog:' - version: 0.11.1(typescript@5.5.4)(zod@3.23.8) + version: 0.11.1(typescript@5.5.4)(zod@3.24.1) '@terrazzo/parser': specifier: ^0.1.0 version: 0.1.0 @@ -694,7 +694,7 @@ importers: version: 0.32.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@18.3.3)(postgres@3.4.4)(react@18.3.1) drizzle-zod: specifier: ^0.5.1 - version: 0.5.1(drizzle-orm@0.32.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@18.3.3)(postgres@3.4.4)(react@18.3.1))(zod@3.23.8) + version: 0.5.1(drizzle-orm@0.32.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@18.3.3)(postgres@3.4.4)(react@18.3.1))(zod@3.24.1) postgres: specifier: ^3.4.4 version: 3.4.4 @@ -702,8 +702,8 @@ importers: specifier: ^0.0.1 version: 0.0.1 zod: - specifier: ^3.23.8 - version: 3.23.8 + specifier: 'catalog:' + version: 3.24.1 devDependencies: '@ds-project/eslint': specifier: workspace:* @@ -791,8 +791,8 @@ importers: specifier: ^3.0.0 version: 3.0.0 zod: - specifier: ^3.23.8 - version: 3.23.8 + specifier: 'catalog:' + version: 3.24.1 devDependencies: '@ds-project/eslint': specifier: workspace:* @@ -855,8 +855,8 @@ importers: specifier: ^2.2.1 version: 2.2.1 zod: - specifier: ^3.23.8 - version: 3.23.8 + specifier: 'catalog:' + version: 3.24.1 devDependencies: '@ds-project/api': specifier: workspace:* @@ -8894,9 +8894,6 @@ packages: peerDependencies: zod: '>= 3.11.0' - zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} - zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} @@ -11912,22 +11909,16 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.6.2 - '@t3-oss/env-core@0.11.1(typescript@5.5.4)(zod@3.23.8)': - dependencies: - zod: 3.23.8 - optionalDependencies: - typescript: 5.5.4 - '@t3-oss/env-core@0.11.1(typescript@5.5.4)(zod@3.24.1)': dependencies: zod: 3.24.1 optionalDependencies: typescript: 5.5.4 - '@t3-oss/env-nextjs@0.11.1(typescript@5.5.4)(zod@3.23.8)': + '@t3-oss/env-nextjs@0.11.1(typescript@5.5.4)(zod@3.24.1)': dependencies: - '@t3-oss/env-core': 0.11.1(typescript@5.5.4)(zod@3.23.8) - zod: 3.23.8 + '@t3-oss/env-core': 0.11.1(typescript@5.5.4)(zod@3.24.1) + zod: 3.24.1 optionalDependencies: typescript: 5.5.4 @@ -13587,10 +13578,10 @@ snapshots: postgres: 3.4.4 react: 18.3.1 - drizzle-zod@0.5.1(drizzle-orm@0.32.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@18.3.3)(postgres@3.4.4)(react@18.3.1))(zod@3.23.8): + drizzle-zod@0.5.1(drizzle-orm@0.32.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@18.3.3)(postgres@3.4.4)(react@18.3.1))(zod@3.24.1): dependencies: drizzle-orm: 0.32.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@18.3.3)(postgres@3.4.4)(react@18.3.1) - zod: 3.23.8 + zod: 3.24.1 eastasianwidth@0.2.0: {} @@ -15800,13 +15791,13 @@ snapshots: neo-async@2.6.2: {} - next-safe-action@7.8.1(next@14.2.12(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8): + next-safe-action@7.8.1(next@14.2.12(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.24.1): dependencies: next: 14.2.12(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - zod: 3.23.8 + zod: 3.24.1 next@14.2.12(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8): dependencies: @@ -17718,11 +17709,9 @@ snapshots: optionalDependencies: commander: 9.5.0 - zod-form-data@2.0.2(zod@3.23.8): + zod-form-data@2.0.2(zod@3.24.1): dependencies: - zod: 3.23.8 - - zod@3.23.8: {} + zod: 3.24.1 zod@3.24.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d8ffdbda..2bc6de83 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,4 +30,4 @@ catalog: typescript: ^5.5.4 vite-plugin-singlefile: ^2.0.2 vite: ^5.3.1 - zod: ^3.23.8 + zod: ^3.24.1