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) => (
+
+
+
+
+
+ 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) => (
+
+
+
+
+
+ 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) => (
+
+
+
+
+
+ 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"