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/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..0b3ccda9 --- /dev/null +++ b/apps/engine/src/app/api/email/cron/route.ts @@ -0,0 +1,66 @@ +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'; + +/** + * 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); + + // 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.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, jobIndex) => { + console.log( + `⚙️ (${jobIndex + 1}/${dueEmailJobs.length}) Processing job ${job.id}.` + ); + // Only process jobs of type email + if (job.data?.type !== 'email') { + console.log( + `⏭️ (${jobIndex + 1}/${dueEmailJobs.length}) Skipped job ${job.id}.` + ); + return; + } + + await sendEmail({ + accountId: job.accountId, + subject: job.data.subject, + template: job.data.template, + }); + + await api.jobs.markCompleted({ id: job.id }); + + console.log( + `📧 (${jobIndex + 1}/${dueEmailJobs.length}) Email job ${job.id} processed successfully.` + ); + }) + ); + + 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..e0b1069a 100644 --- a/apps/engine/src/app/api/email/route.tsx +++ b/apps/engine/src/app/api/email/route.tsx @@ -1,14 +1,24 @@ 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'; -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,32 +28,20 @@ export async function POST(request: NextRequest) { const { user, email_data: { token }, - } = wh.verify(payload, headers) as { - user: { - email: string; - }; - email_data: { - token: string; - }; - }; - - const html = await render( - - ); + } = wh.verify(payload, headers) as WebhookPayload; - 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/auth.action.ts b/apps/engine/src/app/auth/_actions/auth.action.ts index 9aa7e2f0..b20acc1e 100644 --- a/apps/engine/src/app/auth/_actions/auth.action.ts +++ b/apps/engine/src/app/auth/_actions/auth.action.ts @@ -1,9 +1,9 @@ 'use server'; import { z } from 'zod'; -import { unprotectedAction } from '@/lib/safe-action'; +import { publicAction } from '@/lib/safe-action'; -export const authAction = unprotectedAction +export const authAction = publicAction .metadata({ actionName: 'authAction' }) .schema( z.object({ 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 9a4d09d7..566f482a 100644 --- a/apps/engine/src/app/auth/_actions/verify-otp.action.ts +++ b/apps/engine/src/app/auth/_actions/verify-otp.action.ts @@ -1,10 +1,13 @@ 'use server'; import { z } from 'zod'; -import { unprotectedAction } from '@/lib/safe-action'; +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 = unprotectedAction +export const verifyOtpAction = publicAction .metadata({ actionName: 'verifyOtpAction' }) .schema( z.object({ @@ -17,20 +20,53 @@ export const verifyOtpAction = unprotectedAction }) ) .action(async ({ ctx, parsedInput: { email, token } }) => { - 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.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, + }; + } } 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..9ee49692 --- /dev/null +++ b/apps/engine/src/app/auth/_utils/schedule-onboarding-emails.ts @@ -0,0 +1,37 @@ +import { config } from '@/config'; +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: 'DS Pro - Ready to sync?', + template: { + key: 'onboarding-1d', + props: { + staticPathUrl: `${config.pageUrl}/static/email`, + }, + }, + }, + }, + { + type: 'email', + accountId, + dueDate: new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString(), // 72 hours + data: { + type: 'email', + subject: 'DS Pro - The Future of Design Tokens', + template: { + key: 'onboarding-3d', + props: { + staticPathUrl: `${config.pageUrl}/static/email`, + }, + }, + }, + }, + ]); +} 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..346c91f2 --- /dev/null +++ b/apps/engine/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/email/cron", + "schedule": "10 10 * * *" + } + ] +} 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..02f25990 --- /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 }) => { + 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..9b318b41 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_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 e63c1ac1..b9a13a64 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,19 @@ export function createServerClient(): ReturnType< }, }); } + +export function createServiceClient(): ReturnType< + typeof createClient +> { + return createJsClient( + env.SUPABASE_URL, + env.SUPABASE_SERVICE_ROLE_KEY, + { + auth: { + autoRefreshToken: false, + persistSession: false, + detectSessionInUrl: false, + }, + } + ); +} 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/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..74016fc3 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", @@ -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/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..7fd8cc0c --- /dev/null +++ b/packages/database/src/schema/jobs.ts @@ -0,0 +1,105 @@ +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 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', [ + '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..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,15 +19,21 @@ }, "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", + "@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/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/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-1-day.tsx b/packages/email/src/templates/onboarding-1-day.tsx new file mode 100644 index 00000000..e4af58a6 --- /dev/null +++ b/packages/email/src/templates/onboarding-1-day.tsx @@ -0,0 +1,213 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Section, + Text, + Button, + Hr, +} from '@react-email/components'; + +interface Onboarding1DayEmailProps { + staticPathUrl?: string; +} + +export const Onboarding1DayEmail = ({ + staticPathUrl = '/static', +}: Onboarding1DayEmailProps) => ( + + + + + DS Pro + Ready to sync? + + Let's Get Your Design Tokens Flowing + + +
+ 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 + you get started and share some exciting updates about what's coming + next. + +
+ +
+ +
+ Quick Start Your Token Sync + + 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. + + +
+ +
+ +
+ 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. + + + +); + +Onboarding1DayEmail.PreviewProps = { + username: 'John', +} as Onboarding1DayEmailProps; + +export default Onboarding1DayEmail; + +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-3-days.tsx b/packages/email/src/templates/onboarding-3-days.tsx new file mode 100644 index 00000000..86adfefb --- /dev/null +++ b/packages/email/src/templates/onboarding-3-days.tsx @@ -0,0 +1,214 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Section, + Text, + Button, + Hr, +} from '@react-email/components'; + +interface Onboarding3DaysEmailProps { + staticPathUrl?: string; +} + +export const Onboarding3DaysEmail = ({ + staticPathUrl = '/static', +}: Onboarding3DaysEmailProps) => ( + + + + + DS Pro + The Future of Design Tokens + Help Shape What's Coming Next + +
+ 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 + 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. + + + +); + +Onboarding3DaysEmail.PreviewProps = { + username: 'John', +} as Onboarding3DaysEmailProps; + +export default Onboarding3DaysEmail; + +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/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.tsx b/packages/email/src/templates/welcome.tsx new file mode 100644 index 00000000..7c5c5355 --- /dev/null +++ b/packages/email/src/templates/welcome.tsx @@ -0,0 +1,212 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Section, + Text, + Button, + Hr, +} from '@react-email/components'; + +interface WelcomeEmailProps { + staticPathUrl?: string; +} + +export const WelcomeEmail = ({ + staticPathUrl = '/static', +}: WelcomeEmailProps) => ( + + + + + DS Pro + Welcome to DS Pro + + Seamless Design Token Synchronization Awaits + + +
+ 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 + 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/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 e0852463..0ba43f7c 100644 --- a/packages/email/tsconfig.json +++ b/packages/email/tsconfig.json @@ -22,9 +22,7 @@ "declaration": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - - "baseUrl": "." + "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 11f71aaa..6ba35aa3 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 @@ -64,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 @@ -143,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) @@ -179,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) @@ -194,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 @@ -238,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:* @@ -400,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: @@ -442,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:* @@ -479,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:* @@ -608,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:* @@ -663,14 +672,14 @@ importers: packages/database: dependencies: '@next/env': - specifier: ^14.2.13 + specifier: 'catalog:' version: 14.2.13 '@t3-oss/env-core': - specifier: ^0.11.1 - version: 0.11.1(typescript@5.5.4)(zod@3.23.8) + specifier: 'catalog:' + 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) + specifier: 'catalog:' + version: 0.11.1(typescript@5.5.4)(zod@3.24.1) '@terrazzo/parser': specifier: ^0.1.0 version: 0.1.0 @@ -685,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 @@ -693,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:* @@ -720,18 +729,33 @@ 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 '@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 +766,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 @@ -764,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:* @@ -828,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:* @@ -4257,9 +4284,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==} @@ -8870,8 +8894,8 @@ 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==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -11885,16 +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)': + '@t3-oss/env-core@0.11.1(typescript@5.5.4)(zod@3.24.1)': dependencies: - zod: 3.23.8 + 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 @@ -12048,7 +12072,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 +12116,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 +12156,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 @@ -13558,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: {} @@ -13601,7 +13621,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 @@ -15771,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: @@ -17689,10 +17709,10 @@ 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.24.1 - zod@3.23.8: {} + zod@3.24.1: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c88625c6..2bc6de83 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 @@ -27,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 diff --git a/turbo.json b/turbo.json index 21daaadb..168af2cd 100644 --- a/turbo.json +++ b/turbo.json @@ -17,6 +17,8 @@ "POSTGRES_URL", "RESEND_API_KEY", "SEND_EMAIL_HOOK_SECRET", + "SERVICE_HOOK_SECRET", + "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_ANON_KEY", "SUPABASE_URL", "VITE_HOST_URL"