From 91459e07824be392fc9c84b58b5d1651f6bc4acc Mon Sep 17 00:00:00 2001 From: haritabh-z01 Date: Tue, 11 Nov 2025 09:54:35 +0530 Subject: [PATCH 1/3] chore: add org -> project hierarchy --- .../(authenticated)/settings/project/page.tsx | 1 + apps/admin/src/lib/auth.ts | 42 +++++- .../src/server/api/routers/project-members.ts | 120 +++++++--------- apps/admin/src/server/api/routers/project.ts | 129 +++++++++++------- apps/admin/src/server/api/trpc.ts | 20 +-- .../0002_add_organization_hierarchy.sql | 47 +++++++ packages/coredb/drizzle/meta/_journal.json | 7 + packages/coredb/src/schema.ts | 36 ++++- 8 files changed, 265 insertions(+), 137 deletions(-) create mode 100644 packages/coredb/drizzle/0002_add_organization_hierarchy.sql diff --git a/apps/admin/src/app/(authenticated)/settings/project/page.tsx b/apps/admin/src/app/(authenticated)/settings/project/page.tsx index ba870b7..8350efb 100644 --- a/apps/admin/src/app/(authenticated)/settings/project/page.tsx +++ b/apps/admin/src/app/(authenticated)/settings/project/page.tsx @@ -40,6 +40,7 @@ export default function ProjectSettings() { }, onSubmit: async ({ value }) => { updateProjectMutation.mutate({ + projectId: project!.id, name: value.name, url: value.url, }); diff --git a/apps/admin/src/lib/auth.ts b/apps/admin/src/lib/auth.ts index 1153bd7..e0e73e9 100644 --- a/apps/admin/src/lib/auth.ts +++ b/apps/admin/src/lib/auth.ts @@ -91,24 +91,24 @@ export const auth = betterAuth({ organization({ schema: { organization: { - modelName: "project", + modelName: "org", }, session: { modelName: "session", fields: { - activeOrganizationId: "activeProjectId", + activeOrganizationId: "activeOrganizationId", }, }, member: { - modelName: "projectUser", + modelName: "orgUser", fields: { - organizationId: "projectId", + organizationId: "orgId", }, }, invitation: { modelName: "invitation", fields: { - organizationId: "projectId", + organizationId: "organizationId", }, }, }, @@ -141,6 +141,38 @@ export const auth = betterAuth({ name: user.name || undefined, }, }); + + // Auto-create default organization for new users + try { + const { org: orgTable, orgUser } = schema; + + // Create default organization + const [newOrg] = await db + .insert(orgTable) + .values({ + name: `${user.name || user.email}'s Organization`, + slug: `org-${user.id.slice(0, 8)}`, + }) + .returning(); + + // Add user as owner of the organization + await db.insert(orgUser).values({ + orgId: newOrg!.id, + userId: user.id, + role: "owner", + }); + + logger.info("Created default organization for new user", { + userId: user.id, + organizationId: newOrg!.id, + }); + } catch (error) { + logger.error("Failed to create default organization for user", { + userId: user.id, + error, + }); + // Don't throw - allow user creation to succeed even if org creation fails + } }, }, }, diff --git a/apps/admin/src/server/api/routers/project-members.ts b/apps/admin/src/server/api/routers/project-members.ts index 27e79e4..83c4676 100644 --- a/apps/admin/src/server/api/routers/project-members.ts +++ b/apps/admin/src/server/api/routers/project-members.ts @@ -214,15 +214,10 @@ async function validateMemberRemoval( export const projectMembersRouter = createTRPCRouter({ /** - * Get all members for the active project. + * Get all members for the active organization. */ listMembers: protectedProcedure.query(async ({ ctx }) => { - if (!ctx.activeProjectId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No active project", - }); - } + const { orgUser } = schema; const rows = await ctx.db .select({ @@ -230,15 +225,44 @@ export const projectMembersRouter = createTRPCRouter({ name: user.name, email: user.email, avatar: user.image, - role: projectUser.role, - joinedAt: projectUser.createdAt, + role: orgUser.role, + joinedAt: orgUser.createdAt, }) - .from(projectUser) - .innerJoin(user, eq(projectUser.userId, user.id)) - .where(eq(projectUser.projectId, ctx.activeProjectId)); + .from(orgUser) + .innerJoin(user, eq(orgUser.userId, user.id)) + .where(eq(orgUser.orgId, ctx.activeOrganizationId)); + + // Get member counts by role + const [totalResult, ownerResult, adminResult] = await Promise.all([ + ctx.db + .select({ count: count() }) + .from(orgUser) + .where(eq(orgUser.orgId, ctx.activeOrganizationId)), + ctx.db + .select({ count: count() }) + .from(orgUser) + .where( + and( + eq(orgUser.orgId, ctx.activeOrganizationId), + eq(orgUser.role, "owner"), + ), + ), + ctx.db + .select({ count: count() }) + .from(orgUser) + .where( + and( + eq(orgUser.orgId, ctx.activeOrganizationId), + eq(orgUser.role, "admin"), + ), + ), + ]); - // Include member counts for UI to use - const counts = await getProjectMemberCounts(ctx.db, ctx.activeProjectId); + const counts = { + total: totalResult[0]?.count ?? 0, + owners: ownerResult[0]?.count ?? 0, + admins: adminResult[0]?.count ?? 0, + }; // Format joinedAt to readable string return { @@ -257,25 +281,21 @@ export const projectMembersRouter = createTRPCRouter({ })), counts, // Include counts for UI validations currentUserId: ctx.userId, // Explicitly provide current user ID - currentUserRole: ctx.projectUserRole, // Provide current user's role for UI permissions + currentUserRole: ctx.organizationUserRole, // Provide current user's role for UI permissions }; }), /** - * Get pending/expired invitations for the active project. + * Get pending/expired invitations for the active organization. */ listInvitations: protectedProcedure.query(async ({ ctx }) => { - if (!ctx.activeProjectId) { - throw new Error("No active project"); - } - const data = await ctx.db .select() .from(invitation) .innerJoin(user, eq(user.id, invitation.inviterId)) .where( and( - eq(invitation.projectId, ctx.activeProjectId), + eq(invitation.organizationId, ctx.activeOrganizationId), eq(invitation.status, "pending"), ), ); @@ -312,15 +332,8 @@ export const projectMembersRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - if (!ctx.activeProjectId || !ctx.userId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No active project or user", - }); - } - // Check if user has permission to invite - if (ctx.projectUserRole === "member") { + if (ctx.organizationUserRole === "member") { throw new TRPCError({ code: "FORBIDDEN", message: @@ -329,7 +342,7 @@ export const projectMembersRouter = createTRPCRouter({ } // Only owners can invite other owners - if (ctx.projectUserRole === "admin" && input.role === "owner") { + if (ctx.organizationUserRole === "admin" && input.role === "owner") { throw new TRPCError({ code: "FORBIDDEN", message: "Only owners can invite other owners", @@ -339,7 +352,7 @@ export const projectMembersRouter = createTRPCRouter({ // Create invitation entry const invitation = await auth.api.createInvitation({ body: { - organizationId: ctx.activeProjectId, + organizationId: ctx.activeOrganizationId, email: input.email, role: input.role, }, @@ -360,26 +373,10 @@ export const projectMembersRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - if (!ctx.activeProjectId || !ctx.userId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No active project or user", - }); - } - - // Validate the role change - await validateRoleChange( - ctx.db, - ctx.activeProjectId, - input.userId, - input.role, - ctx.userId, - ); - // Use Better Auth's API to update the role await auth.api.updateMemberRole({ body: { - organizationId: ctx.activeProjectId, + organizationId: ctx.activeOrganizationId, memberId: input.userId, role: input.role, }, @@ -390,30 +387,15 @@ export const projectMembersRouter = createTRPCRouter({ }), /** - * Remove member from project with validation. + * Remove member from organization with validation. */ remove: protectedProcedure .input(z.object({ userId: z.string() })) .mutation(async ({ ctx, input }) => { - if (!ctx.activeProjectId || !ctx.userId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No active project or user", - }); - } - - // Validate the member removal - await validateMemberRemoval( - ctx.db, - ctx.activeProjectId, - input.userId, - ctx.userId, - ); - // Use Better Auth's API to remove the member await auth.api.removeMember({ body: { - organizationId: ctx.activeProjectId, + organizationId: ctx.activeOrganizationId, memberIdOrEmail: input.userId, }, headers: ctx.headers, @@ -429,7 +411,7 @@ export const projectMembersRouter = createTRPCRouter({ .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Check if user has permission to cancel invitations - if (ctx.projectUserRole === "member") { + if (ctx.organizationUserRole === "member") { throw new TRPCError({ code: "FORBIDDEN", message: @@ -457,7 +439,7 @@ export const projectMembersRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { // Check if user has permission to resend invitations - if (ctx.projectUserRole === "member") { + if (ctx.organizationUserRole === "member") { throw new TRPCError({ code: "FORBIDDEN", message: @@ -466,7 +448,7 @@ export const projectMembersRouter = createTRPCRouter({ } // Only owners can invite other owners - if (ctx.projectUserRole === "admin" && input.role === "owner") { + if (ctx.organizationUserRole === "admin" && input.role === "owner") { throw new TRPCError({ code: "FORBIDDEN", message: "Only owners can invite other owners", @@ -477,7 +459,7 @@ export const projectMembersRouter = createTRPCRouter({ body: { email: input.email, role: input.role, - organizationId: ctx.activeProjectId, + organizationId: ctx.activeOrganizationId, resend: true, }, headers: ctx.headers, diff --git a/apps/admin/src/server/api/routers/project.ts b/apps/admin/src/server/api/routers/project.ts index 5276b80..7ed5a75 100644 --- a/apps/admin/src/server/api/routers/project.ts +++ b/apps/admin/src/server/api/routers/project.ts @@ -1,5 +1,9 @@ import { z } from "zod"; -import { createTRPCRouter, onboardingProcedure } from "@/server/api/trpc"; +import { + createTRPCRouter, + onboardingProcedure, + protectedProcedure, +} from "@/server/api/trpc"; import { schema } from "@/server/db"; const { project, projectSecrets, projectUser } = schema; import assert from "assert"; @@ -8,7 +12,7 @@ import { randomBytes } from "crypto"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; import { appTypes, paymentProviders } from "@/lib/validations/onboarding"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; const slugGenerator = init({ length: 7, @@ -55,32 +59,27 @@ export const projectRouter = createTRPCRouter({ create: onboardingProcedure .input(createProjectSchema) .mutation(async ({ ctx, input }) => { - // Create the project + // Get active organization from session + const activeOrgId = ctx.activeOrganizationId; + if (!activeOrgId) { + throw new Error( + "No active organization. Please create an organization first.", + ); + } + + // Create the project within the organization const [newProject] = await ctx.db .insert(project) .values({ name: input.name, url: input.url, slug: slugGenerator(), + orgId: activeOrgId, }) .returning(); assert(newProject, "Project not created"); - // add membership to the project - await ctx.db.insert(projectUser).values({ - projectId: newProject.id, - userId: ctx.userId, - role: "owner", - }); - - await auth.api.setActiveOrganization({ - body: { - organizationId: newProject.id, - }, - headers: await headers(), - }); - // Generate and create project secrets const clientId = createId(); const clientSecret = randomBytes(32).toString("hex"); @@ -106,13 +105,22 @@ export const projectRouter = createTRPCRouter({ createWithOnboarding: onboardingProcedure .input(createProjectWithOnboardingSchema) .mutation(async ({ ctx, input }) => { - // Create the project with onboarding data + // Get active organization from session + const activeOrgId = ctx.activeOrganizationId; + if (!activeOrgId) { + throw new Error( + "No active organization. Please create an organization first.", + ); + } + + // Create the project with onboarding data within the organization const [newProject] = await ctx.db .insert(project) .values({ name: input.name, url: input.url, slug: slugGenerator(), + orgId: activeOrgId, appType: input.appType, paymentProvider: input.paymentProvider === "other" @@ -125,20 +133,6 @@ export const projectRouter = createTRPCRouter({ assert(newProject, "Project not created"); - // add membership to the project - await ctx.db.insert(projectUser).values({ - projectId: newProject.id, - userId: ctx.userId, - role: "owner", - }); - - await auth.api.setActiveOrganization({ - body: { - organizationId: newProject.id, - }, - headers: await headers(), - }); - // Generate and create project secrets const clientId = createId(); const clientSecret = randomBytes(32).toString("hex"); @@ -161,33 +155,63 @@ export const projectRouter = createTRPCRouter({ }; }), - // Get current project data - getCurrent: onboardingProcedure.query(async ({ ctx }) => { - if (!ctx.activeProjectId) { - throw new Error("No active project"); - } + // Get all projects in the active organization + getAll: protectedProcedure.query(async ({ ctx }) => { + const projects = await ctx.db + .select() + .from(project) + .where(eq(project.orgId, ctx.activeOrganizationId)); + + return projects; + }), - const currentProject = await ctx.db + // Get the first project in the active organization + getCurrent: protectedProcedure.query(async ({ ctx }) => { + const [firstProject] = await ctx.db .select() .from(project) - .where(eq(project.id, ctx.activeProjectId)) + .where(eq(project.orgId, ctx.activeOrganizationId)) .limit(1); - if (!currentProject.length) { - throw new Error("Project not found"); + if (!firstProject) { + throw new Error("No projects found in your organization"); } - return currentProject[0]; + return firstProject; }), - // Update project information - update: onboardingProcedure - .input(updateProjectSchema) - .mutation(async ({ ctx, input }) => { - if (!ctx.activeProjectId) { - throw new Error("No active project"); + // Get a specific project by ID + getById: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + const [projectData] = await ctx.db + .select() + .from(project) + .where( + and( + eq(project.id, input.projectId), + eq(project.orgId, ctx.activeOrganizationId), + ), + ) + .limit(1); + + if (!projectData) { + throw new Error( + "Project not found or does not belong to your organization", + ); } + return projectData; + }), + + // Update project information + update: protectedProcedure + .input( + updateProjectSchema.extend({ + projectId: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { const [updatedProject] = await ctx.db .update(project) .set({ @@ -195,11 +219,16 @@ export const projectRouter = createTRPCRouter({ url: input.url, updatedAt: new Date(), }) - .where(eq(project.id, ctx.activeProjectId)) + .where( + and( + eq(project.id, input.projectId), + eq(project.orgId, ctx.activeOrganizationId), + ), + ) .returning(); if (!updatedProject) { - throw new Error("Failed to update project"); + throw new Error("Failed to update project or project not found"); } return updatedProject; diff --git a/apps/admin/src/server/api/trpc.ts b/apps/admin/src/server/api/trpc.ts index 4569b0c..0838ce3 100644 --- a/apps/admin/src/server/api/trpc.ts +++ b/apps/admin/src/server/api/trpc.ts @@ -33,16 +33,18 @@ export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await auth.api.getSession({ headers, }); - const projectUser = await auth.api.getActiveMember({ + const organizationUser = await auth.api.getActiveMember({ headers, }); return { db, headers, session: session?.session, - activeProjectId: session?.session?.activeOrganizationId, - projectUserId: projectUser?.id, - projectUserRole: projectUser?.role as "owner" | "admin" | "member", + activeOrganizationId: session?.session?.activeOrganizationId, + organizationUserId: organizationUser?.id, + organizationUserRole: organizationUser?.role as "owner" | "admin" | "member", + //! TODO: @haritabh-z01 to be fixed post nc changes + activeProjectId: 'temp-ts', userId: session?.user?.id, logger: logger, }; @@ -136,23 +138,23 @@ export const onboardingProcedure = t.procedure.use(async ({ ctx, next }) => { }); }); -//! these require the presence of an active project id +//! these require the presence of an active organization id export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { console.error("ctx is ", ctx.session); // check both cause ts :) if (!ctx.session || !ctx.userId) { throw new TRPCError({ code: "UNAUTHORIZED" }); } - if (!ctx.activeProjectId || !ctx.projectUserId) { + if (!ctx.activeOrganizationId || !ctx.organizationUserId) { throw new TRPCError({ code: "FORBIDDEN" }); } return next({ ctx: { session: ctx.session, - activeProjectId: ctx.activeProjectId, - projectUserId: ctx.projectUserId, - projectUserRole: ctx.projectUserRole, + activeOrganizationId: ctx.activeOrganizationId, + organizationUserId: ctx.organizationUserId, + organizationUserRole: ctx.organizationUserRole, userId: ctx.userId, }, }); diff --git a/packages/coredb/drizzle/0002_add_organization_hierarchy.sql b/packages/coredb/drizzle/0002_add_organization_hierarchy.sql new file mode 100644 index 0000000..0e77a7a --- /dev/null +++ b/packages/coredb/drizzle/0002_add_organization_hierarchy.sql @@ -0,0 +1,47 @@ +-- Migration: Add organization hierarchy (org -> project -> program) +-- This migration adds organization tables and updates existing tables to support the new hierarchy + +-- Step 1: Create organization table +CREATE TABLE IF NOT EXISTS "organization" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "name" text NOT NULL, + "slug" text, + "logo" text, + "metadata" text, + CONSTRAINT "organization_slug_unique" UNIQUE("slug") +); + +-- Step 2: Create organization_user table (org-level membership) +CREATE TABLE IF NOT EXISTS "organization_user" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text NOT NULL, + CONSTRAINT "organization_user_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organization"("id") ON DELETE cascade ON UPDATE no action, + CONSTRAINT "organization_user_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE cascade ON UPDATE no action +); + +-- Step 3: Add organizationId to project table (nullable for migration) +ALTER TABLE "project" ADD COLUMN IF NOT EXISTS "organization_id" text; +ALTER TABLE "project" ADD CONSTRAINT "project_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organization"("id") ON DELETE cascade ON UPDATE no action; + +-- Step 4: Add organizationId to apikey table (nullable, optional scoping) +ALTER TABLE "apikey" ADD COLUMN IF NOT EXISTS "organization_id" text; +ALTER TABLE "apikey" ADD CONSTRAINT "apikey_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organization"("id") ON DELETE cascade ON UPDATE no action; + +-- Step 5: Rename activeProjectId to activeOrganizationId in session table +ALTER TABLE "session" RENAME COLUMN "active_project_id" TO "active_organization_id"; + +-- Step 6: Add organizationId to invitation table (for org-level invites) +ALTER TABLE "invitation" ADD COLUMN IF NOT EXISTS "organization_id" text; +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organization"("id") ON DELETE cascade ON UPDATE no action; + +-- Step 7: Make invitation.projectId nullable (since invites can be org-level OR project-level) +ALTER TABLE "invitation" ALTER COLUMN "project_id" DROP NOT NULL; + +-- Note: Data migration to populate organizations from existing projects should be done separately +-- This migration only handles schema changes diff --git a/packages/coredb/drizzle/meta/_journal.json b/packages/coredb/drizzle/meta/_journal.json index 6a09ade..97fffff 100644 --- a/packages/coredb/drizzle/meta/_journal.json +++ b/packages/coredb/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1761715884588, "tag": "0001_seed_program_templates", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1762000000000, + "tag": "0002_add_organization_hierarchy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/coredb/src/schema.ts b/packages/coredb/src/schema.ts index 7961544..1008271 100644 --- a/packages/coredb/src/schema.ts +++ b/packages/coredb/src/schema.ts @@ -64,7 +64,7 @@ export const session = pgTable("session", { userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), - activeProjectId: text("active_project_id"), + activeOrganizationId: text("active_organization_id"), impersonatedBy: text("impersonated_by"), }); @@ -91,8 +91,30 @@ export const verification = pgTable("verification", { expiresAt: timestamp("expires_at").notNull(), }); +export const org = pgTable("org", { + ...baseFields("org"), + name: text("name").notNull(), + slug: text("slug").unique(), + logo: text("logo"), + metadata: text("metadata"), +}); + +export const orgUser = pgTable("org_user", { + ...baseFields("orgUser"), + orgId: text("org_id") + .notNull() + .references(() => org.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + role: text("role").notNull(), +}); + export const project = pgTable("project", { ...baseFields("project"), + orgId: text("org_id").references(() => org.id, { + onDelete: "cascade", + }), name: text("name").notNull(), slug: text("slug").unique(), logo: text("logo"), @@ -117,9 +139,12 @@ export const projectUser = pgTable("project_user", { export const invitation = pgTable("invitation", { ...baseFields("invitation"), - projectId: text("project_id") - .notNull() - .references(() => project.id, { onDelete: "cascade" }), + organizationId: text("organization_id").references(() => org.id, { + onDelete: "cascade", + }), + projectId: text("project_id").references(() => project.id, { + onDelete: "cascade", + }), email: text("email").notNull(), role: text("role"), status: text("status").notNull(), @@ -131,6 +156,9 @@ export const invitation = pgTable("invitation", { export const apikey = pgTable("apikey", { ...baseFields("apikey"), + organizationId: text("organization_id").references(() => org.id, { + onDelete: "cascade", + }), name: text("name"), start: text("start"), prefix: text("prefix"), From 3683bb175db118ea8bae4baa93835a8a70c8ab11 Mon Sep 17 00:00:00 2001 From: haritabh-z01 Date: Tue, 11 Nov 2025 10:28:37 +0530 Subject: [PATCH 2/3] chore: project -> product --- apps/admin/.env.example | 4 +- apps/admin/README.md | 8 +- .../(core)/{projects => products}/page.tsx | 64 +- .../(core)/programs/new/page.tsx | 10 +- .../app/(authenticated)/onboarding/layout.tsx | 4 +- .../app/(authenticated)/onboarding/page.tsx | 28 +- .../app/(authenticated)/settings/layout.tsx | 2 +- .../members/_components/users-table.tsx | 2 +- .../(authenticated)/settings/members/page.tsx | 28 +- .../settings/{project => product}/page.tsx | 62 +- .../(authenticated)/settings/profile/page.tsx | 38 +- apps/admin/src/app/api/events/route.ts | 12 +- apps/admin/src/app/api/r/[id]/route.ts | 18 +- .../src/app/api/scripts/widget.js/route.ts | 4 +- .../src/app/api/scripts/widget/init/route.ts | 54 +- apps/admin/src/app/auth/layout.tsx | 2 +- .../activity-table/activity-table-columns.tsx | 2 +- .../onboarding/product-info-step.tsx | 12 +- .../src/components/referral-widget-init.tsx | 4 +- apps/admin/src/components/section-cards.tsx | 2 +- apps/admin/src/env.ts | 4 +- apps/admin/src/lib/email/index.ts | 2 +- apps/admin/src/lib/email/templates.ts | 2 +- apps/admin/src/lib/forms/onboarding-form.tsx | 4 +- apps/admin/src/lib/validations/onboarding.ts | 8 +- apps/admin/src/server/api/root.ts | 12 +- .../admin/src/server/api/routers/analytics.ts | 32 +- apps/admin/src/server/api/routers/events.ts | 38 +- .../src/server/api/routers/participants.ts | 2 +- ...{project-members.ts => product-members.ts} | 62 +- ...{project-secrets.ts => product-secrets.ts} | 24 +- .../api/routers/{project.ts => product.ts} | 118 +- apps/admin/src/server/api/routers/program.ts | 112 +- apps/admin/src/server/api/routers/referral.ts | 4 +- .../src/server/api/routers/reward-rules.ts | 42 +- apps/admin/src/server/api/routers/rewards.ts | 40 +- apps/admin/src/server/api/routers/search.ts | 12 +- apps/admin/src/server/api/routers/user.ts | 84 +- apps/admin/src/server/api/trpc.ts | 7 +- apps/api/README.md | 2 + apps/api/src/handlers/health.ts | 2 +- packages/coredb/drizzle/0003_icy_bedlam.sql | 127 + .../coredb/drizzle/meta/0003_snapshot.json | 2050 +++++++++++++++++ packages/coredb/drizzle/meta/_journal.json | 7 + packages/coredb/src/schema.ts | 44 +- packages/id/README.md | 80 +- packages/id/src/index.test.ts | 120 +- packages/id/src/index.ts | 50 +- packages/types/src/index.ts | 6 +- .../referral-widget-dialog-content.tsx | 6 +- .../referral-widget-dialog-trigger.tsx | 2 +- .../referral-widget-presentation.tsx | 5 +- packages/widget/README.md | 12 +- packages/widget/demo.html | 2 +- packages/widget/src/lib/command-queue.ts | 2 +- packages/widget/src/lib/refref.ts | 12 +- packages/widget/src/lib/store.ts | 2 +- packages/widget/src/widget/index.tsx | 12 +- pnpm-lock.yaml | 12 +- turbo.json | 2 +- 60 files changed, 2859 insertions(+), 667 deletions(-) rename apps/admin/src/app/(authenticated)/(core)/{projects => products}/page.tsx (80%) rename apps/admin/src/app/(authenticated)/settings/{project => product}/page.tsx (78%) rename apps/admin/src/server/api/routers/{project-members.ts => product-members.ts} (89%) rename apps/admin/src/server/api/routers/{project-secrets.ts => product-secrets.ts} (57%) rename apps/admin/src/server/api/routers/{project.ts => product.ts} (63%) create mode 100644 packages/coredb/drizzle/0003_icy_bedlam.sql create mode 100644 packages/coredb/drizzle/meta/0003_snapshot.json diff --git a/apps/admin/.env.example b/apps/admin/.env.example index 9cfacac..4b8443e 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -36,12 +36,12 @@ RESEND_API_KEY="" # Leave these empty to run without the referral widget # REFERRAL_PROGRAM_CLIENT_ID="" # REFERRAL_PROGRAM_CLIENT_SECRET="" -# NEXT_PUBLIC_REFREF_PROJECT_ID="" +# NEXT_PUBLIC_REFREF_PRODUCT_ID="" # NEXT_PUBLIC_REFREF_PROGRAM_ID="" # PostHog Analytics (OPTIONAL) # Required only if you want to enable analytics and feature flags -# Get your project key from: https://posthog.com/ +# Get your product key from: https://posthog.com/ # Leave NEXT_PUBLIC_POSTHOG_KEY empty to disable PostHog entirely (no console errors) # Set NEXT_PUBLIC_POSTHOG_ENABLED to false to opt out of tracking while keeping PostHog initialized NEXT_PUBLIC_POSTHOG_KEY="" diff --git a/apps/admin/README.md b/apps/admin/README.md index 548e00d..70c8930 100644 --- a/apps/admin/README.md +++ b/apps/admin/README.md @@ -1,12 +1,12 @@ # Create T3 App -This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. +This is a [T3 Stack](https://create.t3.gg/) product bootstrapped with `create-t3-app`. ## What's next? How do I make an app with this? -We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. +We try to keep this product as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. -If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. +If you are not familiar with the different technologies used in this product, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. - [Next.js](https://nextjs.org) - [NextAuth.js](https://next-auth.js.org) @@ -32,7 +32,7 @@ Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/ver ## Authentication and Route Protection -This project implements a comprehensive authentication system with multiple layers of protection: +This product implements a comprehensive authentication system with multiple layers of protection: ### Route Protection Layers diff --git a/apps/admin/src/app/(authenticated)/(core)/projects/page.tsx b/apps/admin/src/app/(authenticated)/(core)/products/page.tsx similarity index 80% rename from apps/admin/src/app/(authenticated)/(core)/projects/page.tsx rename to apps/admin/src/app/(authenticated)/(core)/products/page.tsx index 6fcf901..9740d34 100644 --- a/apps/admin/src/app/(authenticated)/(core)/projects/page.tsx +++ b/apps/admin/src/app/(authenticated)/(core)/products/page.tsx @@ -38,33 +38,33 @@ import { AlertDialogTitle, } from "@refref/ui/components/alert-dialog"; -export default function ProjectsPage() { +export default function ProductsPage() { const router = useRouter(); const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [projectToDelete, setProjectToDelete] = useState(null); + const [productToDelete, setProductToDelete] = useState(null); const utils = api.useUtils(); - // const { data: projects, isLoading } = api.project.getAll.useQuery(); - const projects: any[] = []; + // const { data: products, isLoading } = api.product.getAll.useQuery(); + const products: any[] = []; const isLoading = false; - const createProject = api.project.create.useMutation({ + const createProduct = api.product.create.useMutation({ onSuccess: () => { setOpen(false); setName(""); - // utils.project.getAll.invalidate(); + // utils.product.getAll.invalidate(); }, }); - // const deleteProject = api.project.delete.useMutation({ + // const deleteProduct = api.product.delete.useMutation({ // onSuccess: () => { // setDeleteDialogOpen(false); - // // utils.project.getAll.invalidate(); + // // utils.product.getAll.invalidate(); // }, // }); - const deleteProject = { + const deleteProduct = { mutate: (data: any) => { console.log("Delete not implemented", data); }, @@ -73,15 +73,15 @@ export default function ProjectsPage() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - createProject.mutate({ + createProduct.mutate({ name, url: `https://example.com/${name.toLowerCase().replace(/\s+/g, "-")}`, }); }; const handleDelete = () => { - if (projectToDelete) { - // deleteProject.mutate({ id: projectToDelete }); + if (productToDelete) { + // deleteProduct.mutate({ id: productToDelete }); console.log("Delete not implemented"); } }; @@ -90,7 +90,7 @@ export default function ProjectsPage() { return
Loading...
; } - const breadcrumbs = [{ label: "Projects", href: "/projects" }]; + const breadcrumbs = [{ label: "Products", href: "/products" }]; return ( <> @@ -101,15 +101,15 @@ export default function ProjectsPage() {
- Create Project + Create Product - Create a new project to organize your referral programs. + Create a new product to organize your referral programs.
@@ -119,13 +119,13 @@ export default function ProjectsPage() { id="name" value={name} onChange={(e) => setName(e.target.value)} - placeholder="Enter project name" + placeholder="Enter product name" />
-
@@ -136,10 +136,10 @@ export default function ProjectsPage() {
- {projects?.map((project) => ( - + {products?.map((product) => ( + -

{project.name}

+

{product.name}

@@ -149,9 +149,9 @@ export default function ProjectsPage() { - Project Settings + Product Settings - View your project's client ID and secret for JWT + View your product's client ID and secret for JWT generation. @@ -160,7 +160,7 @@ export default function ProjectsPage() { @@ -169,7 +169,7 @@ export default function ProjectsPage() { @@ -187,12 +187,12 @@ export default function ProjectsPage() { { - setProjectToDelete(project.id); + setProductToDelete(product.id); setDeleteDialogOpen(true); }} > - Delete Project + Delete Product @@ -212,9 +212,9 @@ export default function ProjectsPage() { - Delete Project + Delete Product - Are you sure you want to delete this project? This action cannot + Are you sure you want to delete this product? This action cannot be undone. @@ -223,9 +223,9 @@ export default function ProjectsPage() { - {deleteProject.isPending ? "Deleting..." : "Delete"} + {deleteProduct.isPending ? "Deleting..." : "Delete"} diff --git a/apps/admin/src/app/(authenticated)/(core)/programs/new/page.tsx b/apps/admin/src/app/(authenticated)/(core)/programs/new/page.tsx index b3f09ec..6a68918 100644 --- a/apps/admin/src/app/(authenticated)/(core)/programs/new/page.tsx +++ b/apps/admin/src/app/(authenticated)/(core)/programs/new/page.tsx @@ -12,7 +12,7 @@ export default function NewProgramPage() { const searchParams = useSearchParams(); const templateId = searchParams?.get("templateId"); const templateName = searchParams?.get("title"); - const { data: activeProject } = authClient.useActiveOrganization(); + const { data: activeProduct } = authClient.useActiveOrganization(); // Create program mutation const createProgram = api.program.create.useMutation({ @@ -31,9 +31,9 @@ export default function NewProgramPage() { if (!templateId) router.replace("/programs"); }, [templateId, router]); - // Create program as soon as templateId and activeProject are available + // Create program as soon as templateId and activeProduct are available useEffect(() => { - if (!templateId || !activeProject?.id) return; + if (!templateId || !activeProduct?.id) return; // Only trigger if not already loading or succeeded if ( !createProgram.isPending && @@ -43,11 +43,11 @@ export default function NewProgramPage() { createProgram.mutate({ name: templateName ?? "Untitled Program", description: "", - projectId: activeProject.id, + productId: activeProduct.id, templateId, }); } - }, [templateId, activeProject, createProgram]); + }, [templateId, activeProduct, createProgram]); // Minimal loading spinner return ( diff --git a/apps/admin/src/app/(authenticated)/onboarding/layout.tsx b/apps/admin/src/app/(authenticated)/onboarding/layout.tsx index 2ef68ea..da78072 100644 --- a/apps/admin/src/app/(authenticated)/onboarding/layout.tsx +++ b/apps/admin/src/app/(authenticated)/onboarding/layout.tsx @@ -10,8 +10,8 @@ export default async function OnboardingLayout({ const organizations = await auth.api.listOrganizations({ headers: await headers(), }); - const hasProjects = organizations.length > 0; - if (hasProjects) { + const hasProducts = organizations.length > 0; + if (hasProducts) { redirect("/programs"); } diff --git a/apps/admin/src/app/(authenticated)/onboarding/page.tsx b/apps/admin/src/app/(authenticated)/onboarding/page.tsx index 635ce30..0660e18 100644 --- a/apps/admin/src/app/(authenticated)/onboarding/page.tsx +++ b/apps/admin/src/app/(authenticated)/onboarding/page.tsx @@ -48,20 +48,20 @@ export default function OnboardingPage() { const userEmail = session?.user?.email; const [currentStep, setCurrentStep] = useState(1); - const createProject = api.project.createWithOnboarding.useMutation({ + const createProduct = api.product.createWithOnboarding.useMutation({ onSuccess: () => { - toast.success("Project created successfully!"); + toast.success("Product created successfully!"); router.push("/programs"); }, onError: (error) => { - toast.error(error.message || "Failed to create project"); + toast.error(error.message || "Failed to create product"); }, }); const form = useOnboardingForm({ defaultValues: { - projectName: "", - projectUrl: "", + productName: "", + productUrl: "", appType: "saas", paymentProvider: "stripe", otherPaymentProvider: "", @@ -71,13 +71,13 @@ export default function OnboardingPage() { }, onSubmit: async ({ value }) => { // Transform URL if needed - let url = value.projectUrl; + let url = value.productUrl; if (!url.match(/^https?:\/\//)) { url = `https://${url}`; } - createProject.mutate({ - name: value.projectName, + createProduct.mutate({ + name: value.productName, url, appType: value.appType, paymentProvider: value.paymentProvider, @@ -136,7 +136,7 @@ export default function OnboardingPage() {
{/* Header */}
-

Setup your project

+

Setup your product

{userEmail}
{currentUserRole && currentUserRole !== "member" && ( diff --git a/apps/admin/src/app/(authenticated)/settings/project/page.tsx b/apps/admin/src/app/(authenticated)/settings/product/page.tsx similarity index 78% rename from apps/admin/src/app/(authenticated)/settings/project/page.tsx rename to apps/admin/src/app/(authenticated)/settings/product/page.tsx index 8350efb..7629a04 100644 --- a/apps/admin/src/app/(authenticated)/settings/project/page.tsx +++ b/apps/admin/src/app/(authenticated)/settings/product/page.tsx @@ -11,57 +11,57 @@ import { Separator } from "@refref/ui/components/separator"; import { useForm } from "@tanstack/react-form"; import { z } from "zod"; -export default function ProjectSettings() { +export default function ProductSettings() { const { - data: project, + data: product, isLoading, refetch, - } = api.project.getCurrent.useQuery(); - const updateProjectMutation = api.project.update.useMutation({ + } = api.product.getCurrent.useQuery(); + const updateProductMutation = api.product.update.useMutation({ onSuccess: () => { - toast.success("Project updated successfully"); + toast.success("Product updated successfully"); refetch(); }, onError: () => { - toast.error("Failed to update project"); + toast.error("Failed to update product"); }, }); // Zod validation schema - const projectSchema = z.object({ + const productSchema = z.object({ name: z.string().min(1, "Name is required").max(100, "Name is too long"), url: z.string().url({ message: "Invalid URL" }), }); const form = useForm({ defaultValues: { - name: project?.name || "", - url: project?.url || "", + name: product?.name || "", + url: product?.url || "", }, onSubmit: async ({ value }) => { - updateProjectMutation.mutate({ - projectId: project!.id, + updateProductMutation.mutate({ + productId: product!.id, name: value.name, url: value.url, }); }, }); - // Update form values when project data loads + // Update form values when product data loads useEffect(() => { - if (project) { - form.setFieldValue("name", project.name); - form.setFieldValue("url", project.url || ""); + if (product) { + form.setFieldValue("name", product.name); + form.setFieldValue("url", product.url || ""); } - }, [project, form]); + }, [product, form]); if (isLoading) { return (
-

Project

+

Product

- Manage your project information and settings + Manage your product information and settings

@@ -84,9 +84,9 @@ export default function ProjectSettings() {
{/* Header Section */}
-

Project

+

Product

- Manage your project information and settings + Manage your product information and settings

@@ -99,17 +99,17 @@ export default function ProjectSettings() { }} > - {/* Project Name */} + {/* Product Name */}
{ - const result = projectSchema.shape.name.safeParse(value); + const result = productSchema.shape.name.safeParse(value); return result.success ? undefined : result.error.issues[0]?.message; @@ -124,8 +124,8 @@ export default function ProjectSettings() { onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} className="bg-background border-border text-foreground w-80 max-w-sm" - disabled={updateProjectMutation.isPending} - placeholder="Enter project name" + disabled={updateProductMutation.isPending} + placeholder="Enter product name" /> {field.state.meta.errors && (

@@ -139,17 +139,17 @@ export default function ProjectSettings() { - {/* Project URL */} + {/* Product URL */}

{ - const result = projectSchema.shape.url.safeParse(value); + const result = productSchema.shape.url.safeParse(value); return result.success ? undefined : result.error.issues[0]?.message; @@ -164,7 +164,7 @@ export default function ProjectSettings() { onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} className="bg-background border-border text-foreground w-80 max-w-sm" - disabled={updateProjectMutation.isPending} + disabled={updateProductMutation.isPending} placeholder="https://example.com" /> {field.state.meta.errors && ( @@ -183,9 +183,9 @@ export default function ProjectSettings() {
diff --git a/apps/admin/src/app/(authenticated)/settings/profile/page.tsx b/apps/admin/src/app/(authenticated)/settings/profile/page.tsx index c94988a..c465060 100644 --- a/apps/admin/src/app/(authenticated)/settings/profile/page.tsx +++ b/apps/admin/src/app/(authenticated)/settings/profile/page.tsx @@ -23,7 +23,7 @@ import { toast } from "sonner"; export default function ProfileSettings() { const { data: user, isLoading, refetch } = api.user.getProfile.useQuery(); - const { data: canLeaveProject } = api.user.canLeaveProject.useQuery(); + const { data: canLeaveProduct } = api.user.canLeaveProduct.useQuery(); const updateProfileMutation = api.user.updateProfile.useMutation({ onSuccess: () => { toast.success("Profile updated successfully"); @@ -33,14 +33,14 @@ export default function ProfileSettings() { toast.error(error.message || "Failed to update profile"); }, }); - const leaveProjectMutation = api.user.leaveProject.useMutation({ + const leaveProductMutation = api.user.leaveProduct.useMutation({ onSuccess: () => { - toast.success("Successfully left the project"); + toast.success("Successfully left the product"); // Redirect to onboarding or dashboard window.location.href = "/"; }, onError: (error) => { - toast.error(error.message || "Failed to leave project"); + toast.error(error.message || "Failed to leave product"); }, }); @@ -192,7 +192,7 @@ export default function ProfileSettings() { {/* Workspace Access Section */}

- Project access + Product access

@@ -200,11 +200,11 @@ export default function ProfileSettings() {

- Remove yourself from project + Remove yourself from product

- {canLeaveProject && !canLeaveProject.canLeave && ( + {canLeaveProduct && !canLeaveProduct.canLeave && (

- {canLeaveProject.reason} + {canLeaveProduct.reason}

)}
@@ -213,34 +213,34 @@ export default function ProfileSettings() { - Leave Project + Leave Product - Are you sure you want to leave this project? You will - lose access to all project data and settings. This + Are you sure you want to leave this product? You will + lose access to all product data and settings. This action cannot be undone. Cancel leaveProjectMutation.mutate()} - disabled={leaveProjectMutation.isPending} + onClick={() => leaveProductMutation.mutate()} + disabled={leaveProductMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {leaveProjectMutation.isPending + {leaveProductMutation.isPending ? "Leaving..." - : "Leave Project"} + : "Leave Product"} diff --git a/apps/admin/src/app/api/events/route.ts b/apps/admin/src/app/api/events/route.ts index c84ed95..7534f6a 100644 --- a/apps/admin/src/app/api/events/route.ts +++ b/apps/admin/src/app/api/events/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { api } from "@/trpc/server"; import { db, schema } from "@/server/db"; -const { participant, referral, project } = schema; +const { participant, referral, product } = schema; import { eq, and } from "drizzle-orm"; import { eventMetadataV1Schema, type EventMetadataV1Type } from "@refref/types"; @@ -10,7 +10,7 @@ import { eventMetadataV1Schema, type EventMetadataV1Type } from "@refref/types"; const BaseEvent = z.object({ eventType: z.string(), // discriminant timestamp: z.string().datetime(), // ISO 8601 - projectId: z.string(), // Project ID is required + productId: z.string(), // Product ID is required programId: z.string().optional(), // Program ID is optional }); @@ -76,7 +76,7 @@ export async function POST(request: Request) { .from(participant) .where( and( - eq(participant.projectId, eventData.projectId), + eq(participant.productId, eventData.productId), eq(participant.externalId, eventData.payload.userId), ), ) @@ -89,7 +89,7 @@ export async function POST(request: Request) { const [newParticipant] = await tx .insert(participant) .values({ - projectId: eventData.projectId, + productId: eventData.productId, externalId: eventData.payload.userId, email: eventData.payload.email, name: eventData.payload.name, @@ -120,7 +120,7 @@ export async function POST(request: Request) { .from(participant) .where( and( - eq(participant.projectId, eventData.projectId), + eq(participant.productId, eventData.productId), eq(participant.externalId, eventData.payload.userId), ), ) @@ -160,7 +160,7 @@ export async function POST(request: Request) { // Create the event using our tRPC router (outside transaction for now) const newEvent = await api.events.create({ - projectId: eventData.projectId, + productId: eventData.productId, programId: eventData.programId, eventType: eventData.eventType, participantId: result.participantId, diff --git a/apps/admin/src/app/api/r/[id]/route.ts b/apps/admin/src/app/api/r/[id]/route.ts index 6aa1ed0..698dc15 100644 --- a/apps/admin/src/app/api/r/[id]/route.ts +++ b/apps/admin/src/app/api/r/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { db, schema } from "@/server/db"; -const { referralLink, participant, project } = schema; +const { referralLink, participant, product } = schema; import { eq } from "drizzle-orm"; /** @@ -35,19 +35,19 @@ export async function GET( return new NextResponse("Participant not found", { status: 404 }); } - // Look up the project to get the redirect URL - const projectRecord = await db.query.project.findFirst({ - where: eq(project.id, participantRecord.projectId), + // Look up the product to get the redirect URL + const productRecord = await db.query.product.findFirst({ + where: eq(product.id, participantRecord.productId), }); - // Use project URL if available, otherwise fallback to environment variable - const redirectUrl = projectRecord?.url; + // Use product URL if available, otherwise fallback to environment variable + const redirectUrl = productRecord?.url; if (!redirectUrl) { console.error("No redirect URL configured", { - projectId: participantRecord.projectId, + productId: participantRecord.productId, }); - return new NextResponse("Redirect URL not configured for this project", { + return new NextResponse("Redirect URL not configured for this product", { status: 500, }); } @@ -68,7 +68,7 @@ export async function GET( }); searchParams.set("rfc", id); - // Redirect with 307 to the project URL with encoded params + // Redirect with 307 to the product URL with encoded params return NextResponse.redirect( `${redirectUrl}?${searchParams.toString()}`, 307, diff --git a/apps/admin/src/app/api/scripts/widget.js/route.ts b/apps/admin/src/app/api/scripts/widget.js/route.ts index 3de622b..d97c36b 100644 --- a/apps/admin/src/app/api/scripts/widget.js/route.ts +++ b/apps/admin/src/app/api/scripts/widget.js/route.ts @@ -12,12 +12,12 @@ export async function GET() { // Check if referral program IDs are configured const isConfigured = - env.NEXT_PUBLIC_REFREF_PROJECT_ID && env.NEXT_PUBLIC_REFREF_PROGRAM_ID; + env.NEXT_PUBLIC_REFREF_PRODUCT_ID && env.NEXT_PUBLIC_REFREF_PROGRAM_ID; // Return no-op if not configured if (!isConfigured) { return new NextResponse( - "// RefRef widget not loaded: project/program IDs not configured", + "// RefRef widget not loaded: product/program IDs not configured", { headers }, ); } diff --git a/apps/admin/src/app/api/scripts/widget/init/route.ts b/apps/admin/src/app/api/scripts/widget/init/route.ts index 81cb382..29b1ea8 100644 --- a/apps/admin/src/app/api/scripts/widget/init/route.ts +++ b/apps/admin/src/app/api/scripts/widget/init/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { db, schema } from "@/server/db"; -const { participant, referralLink, projectSecrets, program, referral } = schema; +const { participant, referralLink, productSecrets, program, referral } = schema; import { and, asc, desc, eq } from "drizzle-orm"; import { createId } from "@refref/id"; import { createId as createUnprefixedId } from "@paralleldrive/cuid2"; @@ -19,35 +19,35 @@ import { api } from "@/trpc/server"; // JWT verification function async function verifyJWT( token: string, - projectId: string, + productId: string, ): Promise { try { - // First decode the JWT without verification to get the project ID + // First decode the JWT without verification to get the product ID const { payload } = decode(token); const parsedPayload = jwtPayloadSchema.parse(payload); - // Verify the project ID matches - if (parsedPayload.projectId !== projectId) { - console.error("projectId mismatch", { - expected: projectId, - actual: parsedPayload.projectId, + // Verify the product ID matches + if (parsedPayload.productId !== productId) { + console.error("productId mismatch", { + expected: productId, + actual: parsedPayload.productId, }); return null; } - // Get project secret from database - const secret = await db.query.projectSecrets.findFirst({ - where: eq(projectSecrets.projectId, projectId), + // Get product secret from database + const secret = await db.query.productSecrets.findFirst({ + where: eq(productSecrets.productId, productId), }); if (!secret) { - console.error("project secret not found", { - projectId, + console.error("product secret not found", { + productId, }); - throw new Error("Project secret not found"); + throw new Error("Product secret not found"); } - // Verify the JWT with the project's secret + // Verify the JWT with the product's secret const { payload: verifiedPayload } = await jwtVerify( token, new TextEncoder().encode(secret.clientSecret), @@ -71,10 +71,10 @@ export async function POST(request: Request) { ); } - // Parse and validate the request body first to get projectId + // Parse and validate the request body first to get productId const rawBody = await request.json(); const body = widgetInitRequestSchema.parse(rawBody); - const { projectId, referralCode } = body; + const { productId, referralCode } = body; // Extract and verify the JWT const token = authHeader.split(" ")[1]; @@ -85,7 +85,7 @@ export async function POST(request: Request) { ); } - const decoded = await verifyJWT(token, projectId); + const decoded = await verifyJWT(token, productId); if (!decoded) { return NextResponse.json( { error: "Invalid or expired token" }, @@ -93,10 +93,10 @@ export async function POST(request: Request) { ); } - // ensure there is an active program for this project + // ensure there is an active program for this product const activeProgram = await db.query.program.findFirst({ where: and( - eq(program.projectId, projectId), + eq(program.productId, productId), eq(program.status, "active"), ), orderBy: [asc(program.createdAt)], @@ -104,7 +104,7 @@ export async function POST(request: Request) { if (!activeProgram) { return NextResponse.json( - { error: "No active program found for this project" }, + { error: "No active program found for this product" }, { status: 400 }, ); } @@ -112,7 +112,7 @@ export async function POST(request: Request) { // Check if participant already exists const existingParticipant = await db.query.participant.findFirst({ where: and( - eq(participant.projectId, projectId), + eq(participant.productId, productId), eq(participant.externalId, decoded.sub), ), }); @@ -121,12 +121,12 @@ export async function POST(request: Request) { .insert(participant) .values({ externalId: decoded.sub, - projectId, + productId, email: decoded.email, name: decoded.name, }) .onConflictDoUpdate({ - target: [participant.projectId, participant.externalId], + target: [participant.productId, participant.externalId], set: { email: decoded.email, name: decoded.name, @@ -174,7 +174,7 @@ export async function POST(request: Request) { // Create signup event for reward processing try { await api.events.create({ - projectId, + productId, programId: activeProgram.id, eventType: "signup", participantId: participantRecord.id, @@ -251,10 +251,10 @@ export async function POST(request: Request) { } if ( error instanceof Error && - error.message === "Project secret not found" + error.message === "Product secret not found" ) { return NextResponse.json( - { error: "Invalid project or project not configured" }, + { error: "Invalid product or product not configured" }, { status: 401 }, ); } diff --git a/apps/admin/src/app/auth/layout.tsx b/apps/admin/src/app/auth/layout.tsx index 8c47fc7..05a2e90 100644 --- a/apps/admin/src/app/auth/layout.tsx +++ b/apps/admin/src/app/auth/layout.tsx @@ -16,7 +16,7 @@ export default async function AuthLayout({ headers: await headers(), }); - // Redirect to home if user is already authenticated (will redirect to project) + // Redirect to home if user is already authenticated (will redirect to product) if (session) { redirect("/"); } diff --git a/apps/admin/src/components/activity-table/activity-table-columns.tsx b/apps/admin/src/components/activity-table/activity-table-columns.tsx index 0b07074..1b3d06d 100644 --- a/apps/admin/src/components/activity-table/activity-table-columns.tsx +++ b/apps/admin/src/components/activity-table/activity-table-columns.tsx @@ -15,7 +15,7 @@ import { toast } from "sonner"; export interface Activity { id: string; - projectId: string; + productId: string; programId: string | null; programName: string | null; participantId: string | null; diff --git a/apps/admin/src/components/onboarding/product-info-step.tsx b/apps/admin/src/components/onboarding/product-info-step.tsx index 2dd1e38..f30edf7 100644 --- a/apps/admin/src/components/onboarding/product-info-step.tsx +++ b/apps/admin/src/components/onboarding/product-info-step.tsx @@ -8,8 +8,8 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; export const ProductInfoStep = withFieldGroup({ // These values are only used for type-checking, not at runtime defaultValues: { - projectName: "", - projectUrl: "", + productName: "", + productUrl: "", }, props: { onNext: () => {}, @@ -62,9 +62,9 @@ export const ProductInfoStep = withFieldGroup({
{(field) => ( @@ -76,9 +76,9 @@ export const ProductInfoStep = withFieldGroup({ {(field) => ( diff --git a/apps/admin/src/components/referral-widget-init.tsx b/apps/admin/src/components/referral-widget-init.tsx index a991dde..73782da 100644 --- a/apps/admin/src/components/referral-widget-init.tsx +++ b/apps/admin/src/components/referral-widget-init.tsx @@ -15,7 +15,7 @@ export function ReferralWidgetInit() { // Check if referral credentials are configured const isConfigured = Boolean( - env.NEXT_PUBLIC_REFREF_PROJECT_ID && env.NEXT_PUBLIC_REFREF_PROGRAM_ID, + env.NEXT_PUBLIC_REFREF_PRODUCT_ID && env.NEXT_PUBLIC_REFREF_PROGRAM_ID, ); // Only query for token if configured @@ -34,7 +34,7 @@ export function ReferralWidgetInit() { window.RefRef.push([ "init", { - projectId: env.NEXT_PUBLIC_REFREF_PROJECT_ID, + productId: env.NEXT_PUBLIC_REFREF_PRODUCT_ID, programId: env.NEXT_PUBLIC_REFREF_PROGRAM_ID, participantId: "dfsdfs", token: data.token, diff --git a/apps/admin/src/components/section-cards.tsx b/apps/admin/src/components/section-cards.tsx index 0145e05..fd83739 100644 --- a/apps/admin/src/components/section-cards.tsx +++ b/apps/admin/src/components/section-cards.tsx @@ -94,7 +94,7 @@ export function SectionCards() {
Steady performance increase
-
Meets growth projections
+
Meets growth productions
diff --git a/apps/admin/src/env.ts b/apps/admin/src/env.ts index 2f013cc..93ef0f0 100644 --- a/apps/admin/src/env.ts +++ b/apps/admin/src/env.ts @@ -31,7 +31,7 @@ export const env = createEnv({ client: { // NEXT_PUBLIC_CLIENTVAR: z.string(), NEXT_PUBLIC_APP_URL: z.url().default("http://localhost:3000"), - NEXT_PUBLIC_REFREF_PROJECT_ID: z.string().optional(), + NEXT_PUBLIC_REFREF_PRODUCT_ID: z.string().optional(), NEXT_PUBLIC_REFREF_PROGRAM_ID: z.string().optional(), NEXT_PUBLIC_ENABLE_PASSWORD_AUTH: z .enum(["true", "false"]) @@ -75,7 +75,7 @@ export const env = createEnv({ GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, REFERRAL_PROGRAM_CLIENT_ID: process.env.REFERRAL_PROGRAM_CLIENT_ID, REFERRAL_PROGRAM_CLIENT_SECRET: process.env.REFERRAL_PROGRAM_CLIENT_SECRET, - NEXT_PUBLIC_REFREF_PROJECT_ID: process.env.NEXT_PUBLIC_REFREF_PROJECT_ID, + NEXT_PUBLIC_REFREF_PRODUCT_ID: process.env.NEXT_PUBLIC_REFREF_PRODUCT_ID, NEXT_PUBLIC_REFREF_PROGRAM_ID: process.env.NEXT_PUBLIC_REFREF_PROGRAM_ID, NEXT_PUBLIC_ENABLE_PASSWORD_AUTH: process.env.NEXT_PUBLIC_ENABLE_PASSWORD_AUTH, diff --git a/apps/admin/src/lib/email/index.ts b/apps/admin/src/lib/email/index.ts index f96f274..7c97421 100644 --- a/apps/admin/src/lib/email/index.ts +++ b/apps/admin/src/lib/email/index.ts @@ -154,7 +154,7 @@ class EmailService { return this.send({ to: params.email, - subject: "You've been invited to join a project on RefRef", + subject: "You've been invited to join a product on RefRef", html, }); } diff --git a/apps/admin/src/lib/email/templates.ts b/apps/admin/src/lib/email/templates.ts index 7661fb8..07c7fb4 100644 --- a/apps/admin/src/lib/email/templates.ts +++ b/apps/admin/src/lib/email/templates.ts @@ -180,7 +180,7 @@ export const invitationTemplate = ({

Hi there,

- ${inviterDisplay} has invited you to join their project on RefRef as a + ${inviterDisplay} has invited you to join their product on RefRef as a ${role}.

diff --git a/apps/admin/src/lib/forms/onboarding-form.tsx b/apps/admin/src/lib/forms/onboarding-form.tsx index 0010a7c..22fac2c 100644 --- a/apps/admin/src/lib/forms/onboarding-form.tsx +++ b/apps/admin/src/lib/forms/onboarding-form.tsx @@ -8,8 +8,8 @@ import type { appTypes, paymentProviders } from "@/lib/validations/onboarding"; // Define form values type export type OnboardingFormValues = { - projectName: string; - projectUrl: string; + productName: string; + productUrl: string; appType: (typeof appTypes)[number] | undefined; paymentProvider: (typeof paymentProviders)[number] | undefined; otherPaymentProvider: string | undefined; diff --git a/apps/admin/src/lib/validations/onboarding.ts b/apps/admin/src/lib/validations/onboarding.ts index cb5b713..5a5b930 100644 --- a/apps/admin/src/lib/validations/onboarding.ts +++ b/apps/admin/src/lib/validations/onboarding.ts @@ -22,8 +22,8 @@ export const paymentProviders = [ // ─────────────────────────────────────────────────────────────── export const onboardingSchema = z .object({ - projectName: z.string().min(1, "Product name is required").max(100), - projectUrl: z + productName: z.string().min(1, "Product name is required").max(100), + productUrl: z .string() .min(1, "Website URL is required") .transform((val) => { @@ -64,8 +64,8 @@ export const onboardingSchema = z // ─────────────────────────────────────────────────────────────── export const productInfoSchema = onboardingSchema.pick({ - projectName: true, - projectUrl: true, + productName: true, + productUrl: true, }); export const appTypeSchema = onboardingSchema.pick({ diff --git a/apps/admin/src/server/api/root.ts b/apps/admin/src/server/api/root.ts index ac82bf7..1b8d9e1 100644 --- a/apps/admin/src/server/api/root.ts +++ b/apps/admin/src/server/api/root.ts @@ -1,15 +1,15 @@ import { postRouter } from "@/server/api/routers/post"; import { programRouter } from "@/server/api/routers/program"; import { programTemplateRouter } from "@/server/api/routers/program-template"; -import { projectRouter } from "@/server/api/routers/project"; -import { projectSecretsRouter } from "@/server/api/routers/project-secrets"; +import { productRouter } from "@/server/api/routers/product"; +import { productSecretsRouter } from "@/server/api/routers/product-secrets"; import { referralRouter } from "@/server/api/routers/referral"; import { participantsRouter } from "@/server/api/routers/participants"; import { rewardsRouter } from "@/server/api/routers/rewards"; import { eventsRouter } from "@/server/api/routers/events"; import { rewardRulesRouter } from "@/server/api/routers/reward-rules"; import { userRouter } from "@/server/api/routers/user"; -import { projectMembersRouter } from "@/server/api/routers/project-members"; +import { productMembersRouter } from "@/server/api/routers/product-members"; import { searchRouter } from "@/server/api/routers/search"; import { analyticsRouter } from "@/server/api/routers/analytics"; import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc"; @@ -23,15 +23,15 @@ export const appRouter = createTRPCRouter({ post: postRouter, program: programRouter, programTemplate: programTemplateRouter, - project: projectRouter, - projectSecrets: projectSecretsRouter, + product: productRouter, + productSecrets: productSecretsRouter, referral: referralRouter, participants: participantsRouter, rewards: rewardsRouter, events: eventsRouter, rewardRules: rewardRulesRouter, user: userRouter, - projectMembers: projectMembersRouter, + productMembers: productMembersRouter, search: searchRouter, analytics: analyticsRouter, }); diff --git a/apps/admin/src/server/api/routers/analytics.ts b/apps/admin/src/server/api/routers/analytics.ts index b72ae1d..511dc4f 100644 --- a/apps/admin/src/server/api/routers/analytics.ts +++ b/apps/admin/src/server/api/routers/analytics.ts @@ -4,20 +4,20 @@ import { db, schema } from "@/server/db"; import { eq, and, sql, desc, gte, lte, count } from "drizzle-orm"; import { TRPCError } from "@trpc/server"; -const { program: programTable, participant, referral, project } = schema; +const { program: programTable, participant, referral, product } = schema; export const analyticsRouter = createTRPCRouter({ getStats: protectedProcedure .input(z.string()) .query(async ({ ctx, input: programId }) => { - // Verify program belongs to active project + // Verify program belongs to active product const [program] = await ctx.db .select() .from(programTable) .where( and( eq(programTable.id, programId), - eq(programTable.projectId, ctx.activeProjectId), + eq(programTable.productId, ctx.activeProductId), ), ) .limit(1); @@ -29,13 +29,13 @@ export const analyticsRouter = createTRPCRouter({ }); } - // Get participant count for this project + // Get participant count for this product const [participantStats] = await ctx.db .select({ total: count(), }) .from(participant) - .where(eq(participant.projectId, program.projectId)); + .where(eq(participant.productId, program.productId)); // Get referral count for this program const [referralStats] = await ctx.db @@ -44,7 +44,7 @@ export const analyticsRouter = createTRPCRouter({ }) .from(referral) .innerJoin(participant, eq(participant.id, referral.referrerId)) - .where(eq(participant.projectId, program.projectId)); + .where(eq(participant.productId, program.productId)); // Calculate trends (compare last 30 days vs previous 30 days) const thirtyDaysAgo = new Date(); @@ -59,7 +59,7 @@ export const analyticsRouter = createTRPCRouter({ .innerJoin(participant, eq(participant.id, referral.referrerId)) .where( and( - eq(participant.projectId, program.projectId), + eq(participant.productId, program.productId), gte(referral.createdAt, thirtyDaysAgo), ), ); @@ -71,7 +71,7 @@ export const analyticsRouter = createTRPCRouter({ .innerJoin(participant, eq(participant.id, referral.referrerId)) .where( and( - eq(participant.projectId, program.projectId), + eq(participant.productId, program.productId), gte(referral.createdAt, sixtyDaysAgo), lte(referral.createdAt, thirtyDaysAgo), ), @@ -83,7 +83,7 @@ export const analyticsRouter = createTRPCRouter({ .from(participant) .where( and( - eq(participant.projectId, program.projectId), + eq(participant.productId, program.productId), gte(participant.createdAt, thirtyDaysAgo), ), ); @@ -94,7 +94,7 @@ export const analyticsRouter = createTRPCRouter({ .from(participant) .where( and( - eq(participant.projectId, program.projectId), + eq(participant.productId, program.productId), gte(participant.createdAt, sixtyDaysAgo), lte(participant.createdAt, thirtyDaysAgo), ), @@ -137,14 +137,14 @@ export const analyticsRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - // Verify program belongs to active project + // Verify program belongs to active product const [program] = await ctx.db .select() .from(programTable) .where( and( eq(programTable.id, input.programId), - eq(programTable.projectId, ctx.activeProjectId), + eq(programTable.productId, ctx.activeProductId), ), ) .limit(1); @@ -180,7 +180,7 @@ export const analyticsRouter = createTRPCRouter({ .innerJoin(participant, eq(participant.id, referral.referrerId)) .where( and( - eq(participant.projectId, program.projectId), + eq(participant.productId, program.productId), gte(referral.createdAt, startDate), lte(referral.createdAt, endDate), ), @@ -215,14 +215,14 @@ export const analyticsRouter = createTRPCRouter({ getTopParticipants: protectedProcedure .input(z.string()) .query(async ({ ctx, input: programId }) => { - // Verify program belongs to active project + // Verify program belongs to active product const [program] = await ctx.db .select() .from(programTable) .where( and( eq(programTable.id, programId), - eq(programTable.projectId, ctx.activeProjectId), + eq(programTable.productId, ctx.activeProductId), ), ) .limit(1); @@ -244,7 +244,7 @@ export const analyticsRouter = createTRPCRouter({ }) .from(participant) .leftJoin(referral, eq(referral.referrerId, participant.id)) - .where(eq(participant.projectId, program.projectId)) + .where(eq(participant.productId, program.productId)) .groupBy(participant.id, participant.name, participant.email) .orderBy(desc(count(referral.id))) .limit(5); diff --git a/apps/admin/src/server/api/routers/events.ts b/apps/admin/src/server/api/routers/events.ts index 2827028..7d7bbf2 100644 --- a/apps/admin/src/server/api/routers/events.ts +++ b/apps/admin/src/server/api/routers/events.ts @@ -19,7 +19,7 @@ import { processEventForRewards } from "@/server/services/reward-engine"; // Input schema for creating events const createEventSchema = z.object({ - projectId: z.string(), + productId: z.string(), programId: z.string().optional(), eventType: z.string(), // e.g., "signup", "purchase" participantId: z.string().optional(), @@ -82,7 +82,7 @@ export const eventsRouter = createTRPCRouter({ const [newEvent] = await ctx.db .insert(eventTable) .values({ - projectId: input.projectId, + productId: input.productId, programId: input.programId || null, participantId: input.participantId || null, referralId: input.referralId || null, @@ -121,14 +121,14 @@ export const eventsRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const { programId, page, pageSize, status } = input; - // Verify program belongs to active project + // Verify program belongs to active product const [program] = await ctx.db .select() .from(programTable) .where( and( eq(programTable.id, programId), - eq(programTable.projectId, ctx.activeProjectId), + eq(programTable.productId, ctx.activeProductId), ), ) .limit(1); @@ -136,7 +136,7 @@ export const eventsRouter = createTRPCRouter({ if (!program) { throw new TRPCError({ code: "FORBIDDEN", - message: "Program not found or does not belong to your project", + message: "Program not found or does not belong to your product", }); } @@ -216,12 +216,12 @@ export const eventsRouter = createTRPCRouter({ }); } - // Verify event belongs to active project - const projectId = eventData.event.projectId; - if (projectId !== ctx.activeProjectId) { + // Verify event belongs to active product + const productId = eventData.event.productId; + if (productId !== ctx.activeProductId) { throw new TRPCError({ code: "FORBIDDEN", - message: "Event does not belong to your project", + message: "Event does not belong to your product", }); } @@ -242,7 +242,7 @@ export const eventsRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - // Verify event belongs to active project + // Verify event belongs to active product const [existingEvent] = await ctx.db .select() .from(eventTable) @@ -256,10 +256,10 @@ export const eventsRouter = createTRPCRouter({ }); } - if (existingEvent.projectId !== ctx.activeProjectId) { + if (existingEvent.productId !== ctx.activeProductId) { throw new TRPCError({ code: "FORBIDDEN", - message: "Event does not belong to your project", + message: "Event does not belong to your product", }); } @@ -272,7 +272,7 @@ export const eventsRouter = createTRPCRouter({ return updatedEvent; }), - // Get all events across all programs for the active project + // Get all events across all programs for the active product getAll: protectedProcedure .input( z.object({ @@ -353,8 +353,8 @@ export const eventsRouter = createTRPCRouter({ }); } - // Add filter to only show events from user's active project - whereConditions.push(eq(eventTable.projectId, ctx.activeProjectId)); + // Add filter to only show events from user's active product + whereConditions.push(eq(eventTable.productId, ctx.activeProductId)); const where = whereConditions.length > 0 ? and(...whereConditions) : undefined; @@ -414,7 +414,7 @@ export const eventsRouter = createTRPCRouter({ // Format the response const data = events.map((row) => ({ id: row.event.id, - projectId: row.event.projectId, + productId: row.event.productId, programId: row.event.programId, programName: row.program?.name, participantId: row.event.participantId, @@ -440,14 +440,14 @@ export const eventsRouter = createTRPCRouter({ getStatsByProgram: protectedProcedure .input(z.string()) .query(async ({ ctx, input: programId }) => { - // Verify program belongs to active project + // Verify program belongs to active product const [program] = await ctx.db .select() .from(programTable) .where( and( eq(programTable.id, programId), - eq(programTable.projectId, ctx.activeProjectId), + eq(programTable.productId, ctx.activeProductId), ), ) .limit(1); @@ -455,7 +455,7 @@ export const eventsRouter = createTRPCRouter({ if (!program) { throw new TRPCError({ code: "FORBIDDEN", - message: "Program not found or does not belong to your project", + message: "Program not found or does not belong to your product", }); } diff --git a/apps/admin/src/server/api/routers/participants.ts b/apps/admin/src/server/api/routers/participants.ts index 2c030e0..ca56282 100644 --- a/apps/admin/src/server/api/routers/participants.ts +++ b/apps/admin/src/server/api/routers/participants.ts @@ -104,7 +104,7 @@ export const participantsRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const { id } = input; - // Get participant with project validation + // Get participant with product validation const [participantData] = await ctx.db .select() .from(participant) diff --git a/apps/admin/src/server/api/routers/project-members.ts b/apps/admin/src/server/api/routers/product-members.ts similarity index 89% rename from apps/admin/src/server/api/routers/project-members.ts rename to apps/admin/src/server/api/routers/product-members.ts index 83c4676..fdcf89e 100644 --- a/apps/admin/src/server/api/routers/project-members.ts +++ b/apps/admin/src/server/api/routers/product-members.ts @@ -7,31 +7,31 @@ import { count } from "drizzle-orm"; import { auth } from "@/lib/auth"; import { TRPCError } from "@trpc/server"; -const { user, projectUser, invitation } = schema; +const { user, productUser, invitation } = schema; // Helper function to get member counts by role -async function getProjectMemberCounts(db: DBType, projectId: string) { +async function getProductMemberCounts(db: DBType, productId: string) { const [totalResult, ownerResult, adminResult] = await Promise.all([ db .select({ count: count() }) - .from(projectUser) - .where(eq(projectUser.projectId, projectId)), + .from(productUser) + .where(eq(productUser.productId, productId)), db .select({ count: count() }) - .from(projectUser) + .from(productUser) .where( and( - eq(projectUser.projectId, projectId), - eq(projectUser.role, "owner"), + eq(productUser.productId, productId), + eq(productUser.role, "owner"), ), ), db .select({ count: count() }) - .from(projectUser) + .from(productUser) .where( and( - eq(projectUser.projectId, projectId), - eq(projectUser.role, "admin"), + eq(productUser.productId, productId), + eq(productUser.role, "admin"), ), ), ]); @@ -46,7 +46,7 @@ async function getProjectMemberCounts(db: DBType, projectId: string) { // Helper to validate role changes async function validateRoleChange( db: DBType, - projectId: string, + productId: string, userId: string, newRole: string, currentUserId: string, @@ -54,18 +54,18 @@ async function validateRoleChange( // Get current user's role (the one making the change) const [currentUserMembership] = await db .select() - .from(projectUser) + .from(productUser) .where( and( - eq(projectUser.projectId, projectId), - eq(projectUser.userId, currentUserId), + eq(productUser.productId, productId), + eq(productUser.userId, currentUserId), ), ); if (!currentUserMembership) { throw new TRPCError({ code: "FORBIDDEN", - message: "You are not a member of this project", + message: "You are not a member of this product", }); } @@ -80,19 +80,19 @@ async function validateRoleChange( // Get target user's current role const [targetUserMembership] = await db .select() - .from(projectUser) + .from(productUser) .where( - and(eq(projectUser.projectId, projectId), eq(projectUser.userId, userId)), + and(eq(productUser.productId, productId), eq(productUser.userId, userId)), ); if (!targetUserMembership) { throw new TRPCError({ code: "NOT_FOUND", - message: "User is not a member of this project", + message: "User is not a member of this product", }); } - const counts = await getProjectMemberCounts(db, projectId); + const counts = await getProductMemberCounts(db, productId); // Prevent demoting the last owner if ( @@ -132,25 +132,25 @@ async function validateRoleChange( // Helper to validate member removal async function validateMemberRemoval( db: DBType, - projectId: string, + productId: string, userIdToRemove: string, currentUserId: string, ) { // Get current user's role const [currentUserMembership] = await db .select() - .from(projectUser) + .from(productUser) .where( and( - eq(projectUser.projectId, projectId), - eq(projectUser.userId, currentUserId), + eq(productUser.productId, productId), + eq(productUser.userId, currentUserId), ), ); if (!currentUserMembership) { throw new TRPCError({ code: "FORBIDDEN", - message: "You are not a member of this project", + message: "You are not a member of this product", }); } @@ -165,28 +165,28 @@ async function validateMemberRemoval( // Get target user's role const [targetUserMembership] = await db .select() - .from(projectUser) + .from(productUser) .where( and( - eq(projectUser.projectId, projectId), - eq(projectUser.userId, userIdToRemove), + eq(productUser.productId, productId), + eq(productUser.userId, userIdToRemove), ), ); if (!targetUserMembership) { throw new TRPCError({ code: "NOT_FOUND", - message: "User is not a member of this project", + message: "User is not a member of this product", }); } - const counts = await getProjectMemberCounts(db, projectId); + const counts = await getProductMemberCounts(db, productId); // Prevent removing the last member if (counts.total === 1) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Cannot remove the last member of the project", + message: "Cannot remove the last member of the product", }); } @@ -212,7 +212,7 @@ async function validateMemberRemoval( } } -export const projectMembersRouter = createTRPCRouter({ +export const productMembersRouter = createTRPCRouter({ /** * Get all members for the active organization. */ diff --git a/apps/admin/src/server/api/routers/project-secrets.ts b/apps/admin/src/server/api/routers/product-secrets.ts similarity index 57% rename from apps/admin/src/server/api/routers/project-secrets.ts rename to apps/admin/src/server/api/routers/product-secrets.ts index 39764dc..6da8dd8 100644 --- a/apps/admin/src/server/api/routers/project-secrets.ts +++ b/apps/admin/src/server/api/routers/product-secrets.ts @@ -1,41 +1,41 @@ import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { db, schema } from "@/server/db"; -const { project, projectSecrets } = schema; +const { product, productSecrets } = schema; import { eq, and } from "drizzle-orm"; import { TRPCError } from "@trpc/server"; -export const projectSecretsRouter = createTRPCRouter({ +export const productSecretsRouter = createTRPCRouter({ get: protectedProcedure.input(z.string()).query(async ({ ctx, input }) => { const [secrets] = await ctx.db .select() - .from(projectSecrets) - .where(eq(projectSecrets.projectId, input)) + .from(productSecrets) + .where(eq(productSecrets.productId, input)) .limit(1); if (!secrets) { throw new TRPCError({ code: "NOT_FOUND", - message: "No secrets found for this project", + message: "No secrets found for this product", }); } - // Verify project belongs to active organization - const [projectRecord] = await ctx.db + // Verify product belongs to active organization + const [productRecord] = await ctx.db .select() - .from(project) + .from(product) .where( and( - eq(project.id, secrets.projectId), - eq(project.id, ctx.activeProjectId), + eq(product.id, secrets.productId), + eq(product.id, ctx.activeProductId), ), ) .limit(1); - if (!projectRecord) { + if (!productRecord) { throw new TRPCError({ code: "FORBIDDEN", - message: "Project does not belong to your organization", + message: "Product does not belong to your organization", }); } diff --git a/apps/admin/src/server/api/routers/project.ts b/apps/admin/src/server/api/routers/product.ts similarity index 63% rename from apps/admin/src/server/api/routers/project.ts rename to apps/admin/src/server/api/routers/product.ts index 7ed5a75..1481bb0 100644 --- a/apps/admin/src/server/api/routers/project.ts +++ b/apps/admin/src/server/api/routers/product.ts @@ -5,7 +5,7 @@ import { protectedProcedure, } from "@/server/api/trpc"; import { schema } from "@/server/db"; -const { project, projectSecrets, projectUser } = schema; +const { product, productSecrets, productUser } = schema; import assert from "assert"; import { createId, init } from "@paralleldrive/cuid2"; import { randomBytes } from "crypto"; @@ -18,14 +18,14 @@ const slugGenerator = init({ length: 7, }); -// Input validation schema for creating a project -const createProjectSchema = z.object({ +// Input validation schema for creating a product +const createProductSchema = z.object({ name: z.string().min(1, "Name is required").max(100, "Name is too long"), url: z.string().url({ message: "Invalid URL" }), }); -// Input validation schema for creating a project with onboarding data -export const createProjectWithOnboardingSchema = z +// Input validation schema for creating a product with onboarding data +export const createProductWithOnboardingSchema = z .object({ name: z.string().min(1, "Name is required").max(100, "Name is too long"), url: z.string().min(1, "URL is required"), @@ -49,15 +49,15 @@ export const createProjectWithOnboardingSchema = z }, ); -// Input validation schema for updating project -const updateProjectSchema = z.object({ +// Input validation schema for updating product +const updateProductSchema = z.object({ name: z.string().min(1, "Name is required").max(100, "Name is too long"), url: z.string().url({ message: "Invalid URL" }), }); -export const projectRouter = createTRPCRouter({ +export const productRouter = createTRPCRouter({ create: onboardingProcedure - .input(createProjectSchema) + .input(createProductSchema) .mutation(async ({ ctx, input }) => { // Get active organization from session const activeOrgId = ctx.activeOrganizationId; @@ -67,9 +67,9 @@ export const projectRouter = createTRPCRouter({ ); } - // Create the project within the organization - const [newProject] = await ctx.db - .insert(project) + // Create the product within the organization + const [newProduct] = await ctx.db + .insert(product) .values({ name: input.name, url: input.url, @@ -78,32 +78,32 @@ export const projectRouter = createTRPCRouter({ }) .returning(); - assert(newProject, "Project not created"); + assert(newProduct, "Product not created"); - // Generate and create project secrets + // Generate and create product secrets const clientId = createId(); const clientSecret = randomBytes(32).toString("hex"); const [secrets] = await ctx.db - .insert(projectSecrets) + .insert(productSecrets) .values({ - projectId: newProject.id, + productId: newProduct.id, clientId, clientSecret, }) .returning(); - assert(secrets, "Project secrets not created"); + assert(secrets, "Product secrets not created"); return { - ...newProject, + ...newProduct, clientId: secrets.clientId, clientSecret: secrets.clientSecret, // Only returned once during creation }; }), createWithOnboarding: onboardingProcedure - .input(createProjectWithOnboardingSchema) + .input(createProductWithOnboardingSchema) .mutation(async ({ ctx, input }) => { // Get active organization from session const activeOrgId = ctx.activeOrganizationId; @@ -113,9 +113,9 @@ export const projectRouter = createTRPCRouter({ ); } - // Create the project with onboarding data within the organization - const [newProject] = await ctx.db - .insert(project) + // Create the product with onboarding data within the organization + const [newProduct] = await ctx.db + .insert(product) .values({ name: input.name, url: input.url, @@ -131,89 +131,89 @@ export const projectRouter = createTRPCRouter({ }) .returning(); - assert(newProject, "Project not created"); + assert(newProduct, "Product not created"); - // Generate and create project secrets + // Generate and create product secrets const clientId = createId(); const clientSecret = randomBytes(32).toString("hex"); const [secrets] = await ctx.db - .insert(projectSecrets) + .insert(productSecrets) .values({ - projectId: newProject.id, + productId: newProduct.id, clientId, clientSecret, }) .returning(); - assert(secrets, "Project secrets not created"); + assert(secrets, "Product secrets not created"); return { - ...newProject, + ...newProduct, clientId: secrets.clientId, clientSecret: secrets.clientSecret, // Only returned once during creation }; }), - // Get all projects in the active organization + // Get all products in the active organization getAll: protectedProcedure.query(async ({ ctx }) => { - const projects = await ctx.db + const products = await ctx.db .select() - .from(project) - .where(eq(project.orgId, ctx.activeOrganizationId)); + .from(product) + .where(eq(product.orgId, ctx.activeOrganizationId)); - return projects; + return products; }), - // Get the first project in the active organization + // Get the first product in the active organization getCurrent: protectedProcedure.query(async ({ ctx }) => { - const [firstProject] = await ctx.db + const [firstProduct] = await ctx.db .select() - .from(project) - .where(eq(project.orgId, ctx.activeOrganizationId)) + .from(product) + .where(eq(product.orgId, ctx.activeOrganizationId)) .limit(1); - if (!firstProject) { - throw new Error("No projects found in your organization"); + if (!firstProduct) { + throw new Error("No products found in your organization"); } - return firstProject; + return firstProduct; }), - // Get a specific project by ID + // Get a specific product by ID getById: protectedProcedure - .input(z.object({ projectId: z.string() })) + .input(z.object({ productId: z.string() })) .query(async ({ ctx, input }) => { - const [projectData] = await ctx.db + const [productData] = await ctx.db .select() - .from(project) + .from(product) .where( and( - eq(project.id, input.projectId), - eq(project.orgId, ctx.activeOrganizationId), + eq(product.id, input.productId), + eq(product.orgId, ctx.activeOrganizationId), ), ) .limit(1); - if (!projectData) { + if (!productData) { throw new Error( - "Project not found or does not belong to your organization", + "Product not found or does not belong to your organization", ); } - return projectData; + return productData; }), - // Update project information + // Update product information update: protectedProcedure .input( - updateProjectSchema.extend({ - projectId: z.string(), + updateProductSchema.extend({ + productId: z.string(), }), ) .mutation(async ({ ctx, input }) => { - const [updatedProject] = await ctx.db - .update(project) + const [updatedProduct] = await ctx.db + .update(product) .set({ name: input.name, url: input.url, @@ -221,16 +221,16 @@ export const projectRouter = createTRPCRouter({ }) .where( and( - eq(project.id, input.projectId), - eq(project.orgId, ctx.activeOrganizationId), + eq(product.id, input.productId), + eq(product.orgId, ctx.activeOrganizationId), ), ) .returning(); - if (!updatedProject) { - throw new Error("Failed to update project or project not found"); + if (!updatedProduct) { + throw new Error("Failed to update product or product not found"); } - return updatedProject; + return updatedProduct; }), }); diff --git a/apps/admin/src/server/api/routers/program.ts b/apps/admin/src/server/api/routers/program.ts index 5fed8e2..a8c7abc 100644 --- a/apps/admin/src/server/api/routers/program.ts +++ b/apps/admin/src/server/api/routers/program.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { db, schema } from "@/server/db"; const { - project, + product, program: programTable, programTemplate, participant, @@ -72,27 +72,27 @@ export const programRouter = createTRPCRouter({ .min(1, "Name is required") .max(100, "Name is too long"), description: z.string().max(255, "Description is too long").optional(), - projectId: z.string().min(1, "Project is required"), + productId: z.string().min(1, "Product is required"), templateId: z.string().min(1, "Template is required"), }), ) .mutation(async ({ ctx, input }) => { - // Verify project belongs to active organization - const [selectedProject] = await ctx.db + // Verify product belongs to active organization + const [selectedProduct] = await ctx.db .select() - .from(project) + .from(product) .where( and( - eq(project.id, input.projectId), - eq(project.id, ctx.activeProjectId), + eq(product.id, input.productId), + eq(product.id, ctx.activeProductId), ), ) .limit(1); - if (!selectedProject) { + if (!selectedProduct) { throw new TRPCError({ code: "FORBIDDEN", - message: "Project not found or does not belong to your organization", + message: "Product not found or does not belong to your organization", }); } @@ -110,13 +110,13 @@ export const programRouter = createTRPCRouter({ }); } - // Check if a program with this template already exists for this project + // Check if a program with this template already exists for this product const [existingProgram] = await ctx.db .select() .from(programTable) .where( and( - eq(programTable.projectId, input.projectId), + eq(programTable.productId, input.productId), eq(programTable.programTemplateId, input.templateId), ), ) @@ -125,7 +125,7 @@ export const programRouter = createTRPCRouter({ if (existingProgram) { throw new TRPCError({ code: "CONFLICT", - message: `A program with the "${selectedTemplate.templateName}" template already exists in this project. Each template can only be used once per project.`, + message: `A program with the "${selectedTemplate.templateName}" template already exists in this product. Each template can only be used once per product.`, }); } @@ -134,7 +134,7 @@ export const programRouter = createTRPCRouter({ .insert(programTable) .values({ name: input.name, - projectId: input.projectId, + productId: input.productId, programTemplateId: input.templateId, status: "pending_setup", // Initial status config: undefined, @@ -173,7 +173,7 @@ export const programRouter = createTRPCRouter({ const program = await ctx.db.query.program.findFirst({ where: and( eq(programTable.id, input.id), - eq(programTable.projectId, ctx.activeProjectId), + eq(programTable.productId, ctx.activeProductId), ), with: { programTemplate: true, @@ -280,14 +280,14 @@ export const programRouter = createTRPCRouter({ let widgetConfig: WidgetConfigType; if (brandConfig && rewardConfig) { - // Get project details for product name - const [projectData] = await tx + // Get product details for product name + const [productData] = await tx .select() - .from(project) - .where(eq(project.id, ctx.activeProjectId)) + .from(product) + .where(eq(product.id, ctx.activeProductId)) .limit(1); - const productName = projectData?.name || "Our Platform"; + const productName = productData?.name || "Our Platform"; // Generate widget config from template settings widgetConfig = generateWidgetConfigFromTemplate( @@ -347,19 +347,19 @@ export const programRouter = createTRPCRouter({ throw new Error("Program not found"); } - // Verify program belongs to active organization through project - const [projectRecord] = await ctx.db + // Verify program belongs to active organization through product + const [productRecord] = await ctx.db .select() - .from(project) + .from(product) .where( and( - eq(project.id, program.projectId), - eq(project.id, ctx.activeProjectId), + eq(product.id, program.productId), + eq(product.id, ctx.activeProductId), ), ) .limit(1); - if (!projectRecord) { + if (!productRecord) { throw new TRPCError({ code: "FORBIDDEN", message: "Program does not belong to your organization", @@ -385,19 +385,19 @@ export const programRouter = createTRPCRouter({ throw new Error("Program not found"); } - // Verify program belongs to active organization through project - const [projectRecord] = await ctx.db + // Verify program belongs to active organization through product + const [productRecord] = await ctx.db .select() - .from(project) + .from(product) .where( and( - eq(project.id, program.projectId), - eq(project.id, ctx.activeProjectId), + eq(product.id, program.productId), + eq(product.id, ctx.activeProductId), ), ) .limit(1); - if (!projectRecord) { + if (!productRecord) { throw new TRPCError({ code: "FORBIDDEN", message: "Program does not belong to your organization", @@ -425,19 +425,19 @@ export const programRouter = createTRPCRouter({ throw new Error("Program not found"); } - // Verify program belongs to active organization through project - const [projectRecord] = await ctx.db + // Verify program belongs to active organization through product + const [productRecord] = await ctx.db .select() - .from(project) + .from(product) .where( and( - eq(project.id, program.projectId), - eq(project.id, ctx.activeProjectId), + eq(product.id, program.productId), + eq(product.id, ctx.activeProductId), ), ) .limit(1); - if (!projectRecord) { + if (!productRecord) { throw new TRPCError({ code: "FORBIDDEN", message: "Program does not belong to your organization", @@ -484,19 +484,19 @@ export const programRouter = createTRPCRouter({ throw new Error("Program not found"); } - // Verify program belongs to active organization through project - const [projectRecord] = await ctx.db + // Verify program belongs to active organization through product + const [productRecord] = await ctx.db .select() - .from(project) + .from(product) .where( and( - eq(project.id, program.projectId), - eq(project.id, ctx.activeProjectId), + eq(product.id, program.productId), + eq(product.id, ctx.activeProductId), ), ) .limit(1); - if (!projectRecord) { + if (!productRecord) { throw new TRPCError({ code: "FORBIDDEN", message: "Program does not belong to your organization", @@ -542,29 +542,29 @@ export const programRouter = createTRPCRouter({ */ getAll: protectedProcedure.query(async ({ ctx }) => { - // Get all programs through projects belonging to the active organization + // Get all programs through products belonging to the active organization const programs = await ctx.db .select() .from(programTable) - .innerJoin(project, eq(project.id, programTable.projectId)) - .where(eq(project.id, ctx.activeProjectId)) + .innerJoin(product, eq(product.id, programTable.productId)) + .where(eq(product.id, ctx.activeProductId)) .orderBy(asc(programTable.createdAt)); // Get participant and referral counts for each program const programsWithCounts = await Promise.all( programs.map(async ({ program }) => { - // Get participant count for this project + // Get participant count for this product const [participantCount] = await ctx.db .select({ count: count() }) .from(participant) - .where(eq(participant.projectId, program.projectId)); + .where(eq(participant.productId, program.productId)); - // Get referral count through participants in this project + // Get referral count through participants in this product const [referralCount] = await ctx.db .select({ count: count() }) .from(referral) .innerJoin(participant, eq(participant.id, referral.referrerId)) - .where(eq(participant.projectId, program.projectId)); + .where(eq(participant.productId, program.productId)); return { ...program, @@ -598,19 +598,19 @@ export const programRouter = createTRPCRouter({ }); } - // Verify program belongs to active organization through project - const [projectRecord] = await ctx.db + // Verify program belongs to active organization through product + const [productRecord] = await ctx.db .select() - .from(project) + .from(product) .where( and( - eq(project.id, program.projectId), - eq(project.id, ctx.activeProjectId), + eq(product.id, program.productId), + eq(product.id, ctx.activeProductId), ), ) .limit(1); - if (!projectRecord) { + if (!productRecord) { throw new TRPCError({ code: "FORBIDDEN", message: "Program does not belong to your organization", diff --git a/apps/admin/src/server/api/routers/referral.ts b/apps/admin/src/server/api/routers/referral.ts index a6804e9..5f4f84b 100644 --- a/apps/admin/src/server/api/routers/referral.ts +++ b/apps/admin/src/server/api/routers/referral.ts @@ -13,7 +13,7 @@ export const referralRouter = createTRPCRouter({ if ( !env.REFERRAL_PROGRAM_CLIENT_SECRET || !env.REFERRAL_PROGRAM_CLIENT_ID || - !env.NEXT_PUBLIC_REFREF_PROJECT_ID + !env.NEXT_PUBLIC_REFREF_PRODUCT_ID ) { throw new TRPCError({ code: "PRECONDITION_FAILED", @@ -29,7 +29,7 @@ export const referralRouter = createTRPCRouter({ sub: ctx.userId, email: user!.email, name: user!.name, - projectId: env.NEXT_PUBLIC_REFREF_PROJECT_ID, + productId: env.NEXT_PUBLIC_REFREF_PRODUCT_ID, }; const token = await new SignJWT(payload) diff --git a/apps/admin/src/server/api/routers/reward-rules.ts b/apps/admin/src/server/api/routers/reward-rules.ts index c27c9de..474212d 100644 --- a/apps/admin/src/server/api/routers/reward-rules.ts +++ b/apps/admin/src/server/api/routers/reward-rules.ts @@ -21,14 +21,14 @@ export const rewardRulesRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - // Verify program belongs to active project + // Verify program belongs to active product const [program] = await ctx.db .select() .from(programTable) .where( and( eq(programTable.id, input.programId), - eq(programTable.projectId, ctx.activeProjectId), + eq(programTable.productId, ctx.activeProductId), ), ) .limit(1); @@ -36,7 +36,7 @@ export const rewardRulesRouter = createTRPCRouter({ if (!program) { throw new TRPCError({ code: "FORBIDDEN", - message: "Program not found or does not belong to your project", + message: "Program not found or does not belong to your product", }); } @@ -60,14 +60,14 @@ export const rewardRulesRouter = createTRPCRouter({ getByProgram: protectedProcedure .input(z.string()) .query(async ({ ctx, input: programId }) => { - // Verify program belongs to active project + // Verify program belongs to active product const [program] = await ctx.db .select() .from(programTable) .where( and( eq(programTable.id, programId), - eq(programTable.projectId, ctx.activeProjectId), + eq(programTable.productId, ctx.activeProductId), ), ) .limit(1); @@ -75,7 +75,7 @@ export const rewardRulesRouter = createTRPCRouter({ if (!program) { throw new TRPCError({ code: "FORBIDDEN", - message: "Program not found or does not belong to your project", + message: "Program not found or does not belong to your product", }); } @@ -109,11 +109,11 @@ export const rewardRulesRouter = createTRPCRouter({ }); } - // Verify program belongs to active project - if (rule.program.projectId !== ctx.activeProjectId) { + // Verify program belongs to active product + if (rule.program.productId !== ctx.activeProductId) { throw new TRPCError({ code: "FORBIDDEN", - message: "Reward rule does not belong to your project", + message: "Reward rule does not belong to your product", }); } @@ -136,7 +136,7 @@ export const rewardRulesRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const { id, ...updateData } = input; - // Verify rule exists and belongs to active project + // Verify rule exists and belongs to active product const [existingRule] = await ctx.db .select({ rule: rewardRule, @@ -154,10 +154,10 @@ export const rewardRulesRouter = createTRPCRouter({ }); } - if (existingRule.program.projectId !== ctx.activeProjectId) { + if (existingRule.program.productId !== ctx.activeProductId) { throw new TRPCError({ code: "FORBIDDEN", - message: "Reward rule does not belong to your project", + message: "Reward rule does not belong to your product", }); } @@ -177,7 +177,7 @@ export const rewardRulesRouter = createTRPCRouter({ toggleActive: protectedProcedure .input(z.string()) .mutation(async ({ ctx, input: ruleId }) => { - // Verify rule exists and belongs to active project + // Verify rule exists and belongs to active product const [existingRule] = await ctx.db .select({ rule: rewardRule, @@ -195,10 +195,10 @@ export const rewardRulesRouter = createTRPCRouter({ }); } - if (existingRule.program.projectId !== ctx.activeProjectId) { + if (existingRule.program.productId !== ctx.activeProductId) { throw new TRPCError({ code: "FORBIDDEN", - message: "Reward rule does not belong to your project", + message: "Reward rule does not belong to your product", }); } @@ -218,7 +218,7 @@ export const rewardRulesRouter = createTRPCRouter({ delete: protectedProcedure .input(z.string()) .mutation(async ({ ctx, input: ruleId }) => { - // Verify rule exists and belongs to active project + // Verify rule exists and belongs to active product const [existingRule] = await ctx.db .select({ rule: rewardRule, @@ -236,10 +236,10 @@ export const rewardRulesRouter = createTRPCRouter({ }); } - if (existingRule.program.projectId !== ctx.activeProjectId) { + if (existingRule.program.productId !== ctx.activeProductId) { throw new TRPCError({ code: "FORBIDDEN", - message: "Reward rule does not belong to your project", + message: "Reward rule does not belong to your product", }); } @@ -262,14 +262,14 @@ export const rewardRulesRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - // Verify program belongs to active project + // Verify program belongs to active product const [program] = await ctx.db .select() .from(programTable) .where( and( eq(programTable.id, input.programId), - eq(programTable.projectId, ctx.activeProjectId), + eq(programTable.productId, ctx.activeProductId), ), ) .limit(1); @@ -277,7 +277,7 @@ export const rewardRulesRouter = createTRPCRouter({ if (!program) { throw new TRPCError({ code: "FORBIDDEN", - message: "Program not found or does not belong to your project", + message: "Program not found or does not belong to your product", }); } diff --git a/apps/admin/src/server/api/routers/rewards.ts b/apps/admin/src/server/api/routers/rewards.ts index d73af75..9600051 100644 --- a/apps/admin/src/server/api/routers/rewards.ts +++ b/apps/admin/src/server/api/routers/rewards.ts @@ -89,13 +89,13 @@ export const rewardsRouter = createTRPCRouter({ }); } - // Add filter to only show rewards from user's active project - // Join with program to check project ownership - const projectFilter = sql`${rewardTable.programId} IN ( + // Add filter to only show rewards from user's active product + // Join with program to check product ownership + const productFilter = sql`${rewardTable.programId} IN ( SELECT id FROM ${programTable} - WHERE ${programTable.projectId} = ${ctx.activeProjectId} + WHERE ${programTable.productId} = ${ctx.activeProductId} )`; - whereConditions.push(projectFilter); + whereConditions.push(productFilter); const where = whereConditions.length > 0 ? and(...whereConditions) : undefined; @@ -184,7 +184,7 @@ export const rewardsRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - // Verify reward exists and belongs to user's project + // Verify reward exists and belongs to user's product const [existingReward] = await ctx.db .select({ reward: rewardTable, @@ -202,10 +202,10 @@ export const rewardsRouter = createTRPCRouter({ }); } - if (existingReward.program.projectId !== ctx.activeProjectId) { + if (existingReward.program.productId !== ctx.activeProductId) { throw new TRPCError({ code: "FORBIDDEN", - message: "Reward does not belong to your project", + message: "Reward does not belong to your product", }); } @@ -230,7 +230,7 @@ export const rewardsRouter = createTRPCRouter({ markAsDisbursed: protectedProcedure .input(z.object({ rewardId: z.string() })) .mutation(async ({ ctx, input }) => { - // Verify reward exists and belongs to user's project + // Verify reward exists and belongs to user's product const [existingReward] = await ctx.db .select({ reward: rewardTable, @@ -248,10 +248,10 @@ export const rewardsRouter = createTRPCRouter({ }); } - if (existingReward.program.projectId !== ctx.activeProjectId) { + if (existingReward.program.productId !== ctx.activeProductId) { throw new TRPCError({ code: "FORBIDDEN", - message: "Reward does not belong to your project", + message: "Reward does not belong to your product", }); } @@ -320,11 +320,11 @@ export const rewardsRouter = createTRPCRouter({ }); } - // Verify reward belongs to user's project - if (rewardData.program?.projectId !== ctx.activeProjectId) { + // Verify reward belongs to user's product + if (rewardData.program?.productId !== ctx.activeProductId) { throw new TRPCError({ code: "FORBIDDEN", - message: "Reward does not belong to your project", + message: "Reward does not belong to your product", }); } @@ -349,14 +349,14 @@ export const rewardsRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const { participantId, page, pageSize } = input; - // Verify participant belongs to user's project + // Verify participant belongs to user's product const [participantRecord] = await ctx.db .select() .from(participant) .where( and( eq(participant.id, participantId), - eq(participant.projectId, ctx.activeProjectId), + eq(participant.productId, ctx.activeProductId), ), ) .limit(1); @@ -364,7 +364,7 @@ export const rewardsRouter = createTRPCRouter({ if (!participantRecord) { throw new TRPCError({ code: "FORBIDDEN", - message: "Participant not found or does not belong to your project", + message: "Participant not found or does not belong to your product", }); } @@ -411,14 +411,14 @@ export const rewardsRouter = createTRPCRouter({ getStatsByProgram: protectedProcedure .input(z.string()) .query(async ({ ctx, input: programId }) => { - // Verify program belongs to user's project + // Verify program belongs to user's product const [program] = await ctx.db .select() .from(programTable) .where( and( eq(programTable.id, programId), - eq(programTable.projectId, ctx.activeProjectId), + eq(programTable.productId, ctx.activeProductId), ), ) .limit(1); @@ -426,7 +426,7 @@ export const rewardsRouter = createTRPCRouter({ if (!program) { throw new TRPCError({ code: "FORBIDDEN", - message: "Program not found or does not belong to your project", + message: "Program not found or does not belong to your product", }); } diff --git a/apps/admin/src/server/api/routers/search.ts b/apps/admin/src/server/api/routers/search.ts index 891e223..0613c8a 100644 --- a/apps/admin/src/server/api/routers/search.ts +++ b/apps/admin/src/server/api/routers/search.ts @@ -12,9 +12,9 @@ const settingsPages = [ href: `/settings/profile`, }, { - id: "project-settings", - name: "Project", - href: `/settings/project`, + id: "product-settings", + name: "Product", + href: `/settings/product`, }, { id: "appearance-settings", @@ -37,7 +37,7 @@ export const searchRouter = createTRPCRouter({ ) .query(async ({ ctx, input }) => { const { query } = input; - const projectId = ctx.activeProjectId; + const productId = ctx.activeProductId; // Return empty results if query is empty if (!query || query.trim().length === 0) { @@ -63,7 +63,7 @@ export const searchRouter = createTRPCRouter({ ) .where( and( - eq(participant.projectId, projectId), + eq(participant.productId, productId), or( ilike(participant.email, searchPattern), ilike(participant.id, searchPattern), @@ -83,7 +83,7 @@ export const searchRouter = createTRPCRouter({ .from(program) .where( and( - eq(program.projectId, projectId), + eq(program.productId, productId), ilike(program.name, searchPattern), ), ) diff --git a/apps/admin/src/server/api/routers/user.ts b/apps/admin/src/server/api/routers/user.ts index 88d8085..40f5828 100644 --- a/apps/admin/src/server/api/routers/user.ts +++ b/apps/admin/src/server/api/routers/user.ts @@ -9,7 +9,7 @@ import { eq, and, count } from "drizzle-orm"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; -const { user, projectUser } = schema; +const { user, productUser } = schema; // Input validation schema for updating user profile const updateProfileSchema = z.object({ @@ -71,42 +71,42 @@ export const userRouter = createTRPCRouter({ return foundUser[0]; }), - // Check if user can leave the current project - canLeaveProject: protectedProcedure.query(async ({ ctx }) => { - // Get current user's role in the project + // Check if user can leave the current product + canLeaveProduct: protectedProcedure.query(async ({ ctx }) => { + // Get current user's role in the product const currentUserMembership = await ctx.db .select() - .from(projectUser) + .from(productUser) .where( and( - eq(projectUser.projectId, ctx.activeProjectId), - eq(projectUser.userId, ctx.userId), + eq(productUser.productId, ctx.activeProductId), + eq(productUser.userId, ctx.userId), ), ) .limit(1); if (!currentUserMembership.length) { - throw new Error("User is not a member of this project"); + throw new Error("User is not a member of this product"); } const userRole = currentUserMembership[0]!.role; - // Count total members in the project + // Count total members in the product const totalMembersResult = await ctx.db .select({ count: count() }) - .from(projectUser) - .where(eq(projectUser.projectId, ctx.activeProjectId)); + .from(productUser) + .where(eq(productUser.productId, ctx.activeProductId)); const totalMembers = totalMembersResult[0]?.count ?? 0; - // Count admin members in the project + // Count admin members in the product const adminMembersResult = await ctx.db .select({ count: count() }) - .from(projectUser) + .from(productUser) .where( and( - eq(projectUser.projectId, ctx.activeProjectId), - eq(projectUser.role, "admin"), + eq(productUser.productId, ctx.activeProductId), + eq(productUser.role, "admin"), ), ); @@ -117,7 +117,7 @@ export const userRouter = createTRPCRouter({ return { canLeave: false, reason: - "You cannot leave the project as you are the only member. Please add another member or delete the project.", + "You cannot leave the product as you are the only member. Please add another member or delete the product.", }; } @@ -126,7 +126,7 @@ export const userRouter = createTRPCRouter({ return { canLeave: false, reason: - "You cannot leave the project as you are the only admin. Please promote another member to admin first.", + "You cannot leave the product as you are the only admin. Please promote another member to admin first.", }; } @@ -136,42 +136,42 @@ export const userRouter = createTRPCRouter({ }; }), - // Leave the current project - leaveProject: protectedProcedure.mutation(async ({ ctx }) => { + // Leave the current product + leaveProduct: protectedProcedure.mutation(async ({ ctx }) => { // First check if user can leave const canLeaveCheck = await ctx.db .select() - .from(projectUser) + .from(productUser) .where( and( - eq(projectUser.projectId, ctx.activeProjectId), - eq(projectUser.userId, ctx.userId), + eq(productUser.productId, ctx.activeProductId), + eq(productUser.userId, ctx.userId), ), ) .limit(1); if (!canLeaveCheck.length) { - throw new Error("User is not a member of this project"); + throw new Error("User is not a member of this product"); } const userRole = canLeaveCheck[0]!.role; - // Count total members in the project + // Count total members in the product const totalMembersResult = await ctx.db .select({ count: count() }) - .from(projectUser) - .where(eq(projectUser.projectId, ctx.activeProjectId)); + .from(productUser) + .where(eq(productUser.productId, ctx.activeProductId)); const totalMembers = totalMembersResult[0]?.count ?? 0; - // Count admin members in the project + // Count admin members in the product const adminMembersResult = await ctx.db .select({ count: count() }) - .from(projectUser) + .from(productUser) .where( and( - eq(projectUser.projectId, ctx.activeProjectId), - eq(projectUser.role, "admin"), + eq(productUser.productId, ctx.activeProductId), + eq(productUser.role, "admin"), ), ); @@ -179,34 +179,34 @@ export const userRouter = createTRPCRouter({ // Validate that user can leave if (totalMembers === 1) { - throw new Error("Cannot leave project as you are the only member"); + throw new Error("Cannot leave product as you are the only member"); } if (userRole === "admin" && adminMembers === 1) { - throw new Error("Cannot leave project as you are the only admin"); + throw new Error("Cannot leave product as you are the only admin"); } - // Remove user from project + // Remove user from product await ctx.db - .delete(projectUser) + .delete(productUser) .where( and( - eq(projectUser.projectId, ctx.activeProjectId), - eq(projectUser.userId, ctx.userId), + eq(productUser.productId, ctx.activeProductId), + eq(productUser.userId, ctx.userId), ), ); - // If user has other projects, set the first one as active - const otherProjects = await ctx.db + // If user has other products, set the first one as active + const otherProducts = await ctx.db .select() - .from(projectUser) - .where(eq(projectUser.userId, ctx.userId)) + .from(productUser) + .where(eq(productUser.userId, ctx.userId)) .limit(1); - if (otherProjects.length > 0) { + if (otherProducts.length > 0) { await auth.api.setActiveOrganization({ body: { - organizationId: otherProjects[0]!.projectId, + organizationId: otherProducts[0]!.productId, }, headers: await headers(), }); diff --git a/apps/admin/src/server/api/trpc.ts b/apps/admin/src/server/api/trpc.ts index 0838ce3..111ecc9 100644 --- a/apps/admin/src/server/api/trpc.ts +++ b/apps/admin/src/server/api/trpc.ts @@ -42,9 +42,12 @@ export const createTRPCContext = async (opts: { headers: Headers }) => { session: session?.session, activeOrganizationId: session?.session?.activeOrganizationId, organizationUserId: organizationUser?.id, - organizationUserRole: organizationUser?.role as "owner" | "admin" | "member", + organizationUserRole: organizationUser?.role as + | "owner" + | "admin" + | "member", //! TODO: @haritabh-z01 to be fixed post nc changes - activeProjectId: 'temp-ts', + activeProductId: "temp-ts", userId: session?.user?.id, logger: logger, }; diff --git a/apps/api/README.md b/apps/api/README.md index 34f4f2d..8e2a3bf 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -40,10 +40,12 @@ NODE_ENV=development The API uses Vitest and Playwright for integration testing. Tests start a real server instance and make HTTP requests to validate responses. Test structure: + - `test/utils/testServer.ts` - Test server utilities for starting/stopping the server - `test/health.test.ts` - Health endpoint tests Each test suite: + 1. Starts a test server on a random port 2. Creates a Playwright API request context 3. Runs tests against real endpoints diff --git a/apps/api/src/handlers/health.ts b/apps/api/src/handlers/health.ts index 876250c..6bc77ff 100644 --- a/apps/api/src/handlers/health.ts +++ b/apps/api/src/handlers/health.ts @@ -2,7 +2,7 @@ import { FastifyReply, FastifyRequest } from "fastify"; export async function healthHandler( _request: FastifyRequest, - reply: FastifyReply + reply: FastifyReply, ) { return reply.send({ ok: true, diff --git a/packages/coredb/drizzle/0003_icy_bedlam.sql b/packages/coredb/drizzle/0003_icy_bedlam.sql new file mode 100644 index 0000000..76ca58c --- /dev/null +++ b/packages/coredb/drizzle/0003_icy_bedlam.sql @@ -0,0 +1,127 @@ +CREATE TABLE IF NOT EXISTS "org" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "name" text NOT NULL, + "slug" text, + "logo" text, + "metadata" text, + CONSTRAINT "org_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "org_user" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "org_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "project" RENAME TO "product";--> statement-breakpoint +ALTER TABLE "project_secrets" RENAME TO "product_secrets";--> statement-breakpoint +ALTER TABLE "project_user" RENAME TO "product_user";--> statement-breakpoint +ALTER TABLE "event" RENAME COLUMN "project_id" TO "product_id";--> statement-breakpoint +ALTER TABLE "invitation" RENAME COLUMN "project_id" TO "product_id";--> statement-breakpoint +ALTER TABLE "participant" RENAME COLUMN "project_id" TO "product_id";--> statement-breakpoint +ALTER TABLE "program" RENAME COLUMN "project_id" TO "product_id";--> statement-breakpoint +ALTER TABLE "product_secrets" RENAME COLUMN "project_id" TO "product_id";--> statement-breakpoint +ALTER TABLE "product_user" RENAME COLUMN "project_id" TO "product_id";--> statement-breakpoint +ALTER TABLE "participant" DROP CONSTRAINT "participant_project_id_external_id_unique";--> statement-breakpoint +ALTER TABLE "product" DROP CONSTRAINT "project_slug_unique";--> statement-breakpoint +ALTER TABLE "event" DROP CONSTRAINT "event_project_id_project_id_fk"; +--> statement-breakpoint +ALTER TABLE "invitation" DROP CONSTRAINT "invitation_project_id_project_id_fk"; +--> statement-breakpoint +ALTER TABLE "participant" DROP CONSTRAINT "participant_project_id_project_id_fk"; +--> statement-breakpoint +ALTER TABLE "program" DROP CONSTRAINT "program_project_id_project_id_fk"; +--> statement-breakpoint +ALTER TABLE "product_secrets" DROP CONSTRAINT "project_secrets_project_id_project_id_fk"; +--> statement-breakpoint +ALTER TABLE "product_user" DROP CONSTRAINT "project_user_project_id_project_id_fk"; +--> statement-breakpoint +ALTER TABLE "product_user" DROP CONSTRAINT "project_user_user_id_user_id_fk"; +--> statement-breakpoint +DROP INDEX IF EXISTS "event_project_id_idx";--> statement-breakpoint +ALTER TABLE "invitation" ALTER COLUMN "product_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "apikey" ADD COLUMN "organization_id" text;--> statement-breakpoint +ALTER TABLE "invitation" ADD COLUMN "organization_id" text;--> statement-breakpoint +ALTER TABLE "product" ADD COLUMN "org_id" text;--> statement-breakpoint +ALTER TABLE "session" ADD COLUMN "active_organization_id" text;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "org_user" ADD CONSTRAINT "org_user_org_id_org_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."org"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "org_user" ADD CONSTRAINT "org_user_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "apikey" ADD CONSTRAINT "apikey_organization_id_org_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."org"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "event" ADD CONSTRAINT "event_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_org_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."org"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "invitation" ADD CONSTRAINT "invitation_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "participant" ADD CONSTRAINT "participant_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "program" ADD CONSTRAINT "program_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "product" ADD CONSTRAINT "product_org_id_org_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."org"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "product_secrets" ADD CONSTRAINT "product_secrets_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "product_user" ADD CONSTRAINT "product_user_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "product_user" ADD CONSTRAINT "product_user_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "event_product_id_idx" ON "event" USING btree ("product_id");--> statement-breakpoint +ALTER TABLE "session" DROP COLUMN IF EXISTS "active_project_id";--> statement-breakpoint +ALTER TABLE "participant" ADD CONSTRAINT "participant_product_id_external_id_unique" UNIQUE("product_id","external_id");--> statement-breakpoint +ALTER TABLE "product" ADD CONSTRAINT "product_slug_unique" UNIQUE("slug"); \ No newline at end of file diff --git a/packages/coredb/drizzle/meta/0003_snapshot.json b/packages/coredb/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..c83ba79 --- /dev/null +++ b/packages/coredb/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2050 @@ +{ + "id": "bb0dcc75-ebb1-4fd6-814c-86ab52f82c87", + "prevId": "295ecef8-20df-4e6b-a58f-f955e6a8b4ce", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_organization_id_org_id_fk": { + "name": "apikey_organization_id_org_id_fk", + "tableFrom": "apikey", + "tableTo": "org", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "apikey_user_id_user_id_fk": { + "name": "apikey_user_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.event": { + "name": "event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referral_id": { + "name": "referral_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_definition_id": { + "name": "event_definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "event_product_id_idx": { + "name": "event_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_program_id_idx": { + "name": "event_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_participant_id_idx": { + "name": "event_participant_id_idx", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_referral_id_idx": { + "name": "event_referral_id_idx", + "columns": [ + { + "expression": "referral_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_definition_id_idx": { + "name": "event_definition_id_idx", + "columns": [ + { + "expression": "event_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_status_idx": { + "name": "event_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_created_at_idx": { + "name": "event_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_product_id_product_id_fk": { + "name": "event_product_id_product_id_fk", + "tableFrom": "event", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_program_id_program_id_fk": { + "name": "event_program_id_program_id_fk", + "tableFrom": "event", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_participant_id_participant_id_fk": { + "name": "event_participant_id_participant_id_fk", + "tableFrom": "event", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_referral_id_referral_id_fk": { + "name": "event_referral_id_referral_id_fk", + "tableFrom": "event", + "tableTo": "referral", + "columnsFrom": [ + "referral_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_event_definition_id_event_definition_id_fk": { + "name": "event_event_definition_id_event_definition_id_fk", + "tableFrom": "event", + "tableTo": "event_definition", + "columnsFrom": [ + "event_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.event_definition": { + "name": "event_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "event_definition_type_unique": { + "name": "event_definition_type_unique", + "nullsNotDistinct": false, + "columns": [ + "type" + ] + } + } + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_org_id_fk": { + "name": "invitation_organization_id_org_id_fk", + "tableFrom": "invitation", + "tableTo": "org", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_product_id_product_id_fk": { + "name": "invitation_product_id_product_id_fk", + "tableFrom": "invitation", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + }, + "public.org_user": { + "name": "org_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "org_user_org_id_org_id_fk": { + "name": "org_user_org_id_org_id_fk", + "tableFrom": "org_user", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_user_user_id_user_id_fk": { + "name": "org_user_user_id_user_id_fk", + "tableFrom": "org_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.participant": { + "name": "participant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "participant_product_id_product_id_fk": { + "name": "participant_product_id_product_id_fk", + "tableFrom": "participant", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "participant_product_id_external_id_unique": { + "name": "participant_product_id_external_id_unique", + "nullsNotDistinct": false, + "columns": [ + "product_id", + "external_id" + ] + } + } + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_type": { + "name": "app_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "product_org_id_org_id_fk": { + "name": "product_org_id_org_id_fk", + "tableFrom": "product", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "product_slug_unique": { + "name": "product_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + }, + "public.product_secrets": { + "name": "product_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "product_secrets_product_id_product_id_fk": { + "name": "product_secrets_product_id_product_id_fk", + "tableFrom": "product_secrets", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.product_user": { + "name": "product_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "product_user_product_id_product_id_fk": { + "name": "product_user_product_id_product_id_fk", + "tableFrom": "product_user", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_user_user_id_user_id_fk": { + "name": "product_user_user_id_user_id_fk", + "tableFrom": "product_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.program": { + "name": "program", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_template_id": { + "name": "program_template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "program_product_id_product_id_fk": { + "name": "program_product_id_product_id_fk", + "tableFrom": "program", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "program_program_template_id_program_template_id_fk": { + "name": "program_program_template_id_program_template_id_fk", + "tableFrom": "program", + "tableTo": "program_template", + "columnsFrom": [ + "program_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.program_template": { + "name": "program_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "template_name": { + "name": "template_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_participant_id_fk": { + "name": "referral_referrer_id_participant_id_fk", + "tableFrom": "referral", + "tableTo": "participant", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.referral_link": { + "name": "referral_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "referral_link_participant_id_participant_id_fk": { + "name": "referral_link_participant_id_participant_id_fk", + "tableFrom": "referral_link", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referral_link_slug_unique": { + "name": "referral_link_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + }, + "public.reward": { + "name": "reward", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reward_rule_id": { + "name": "reward_rule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_type": { + "name": "reward_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disbursed_at": { + "name": "disbursed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "reward_participant_id_idx": { + "name": "reward_participant_id_idx", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_program_id_idx": { + "name": "reward_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_rule_id_idx": { + "name": "reward_rule_id_idx", + "columns": [ + { + "expression": "reward_rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_event_id_idx": { + "name": "reward_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_status_idx": { + "name": "reward_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_created_at_idx": { + "name": "reward_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_participant_id_participant_id_fk": { + "name": "reward_participant_id_participant_id_fk", + "tableFrom": "reward", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_program_id_program_id_fk": { + "name": "reward_program_id_program_id_fk", + "tableFrom": "reward", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_reward_rule_id_reward_rule_id_fk": { + "name": "reward_reward_rule_id_reward_rule_id_fk", + "tableFrom": "reward", + "tableTo": "reward_rule", + "columnsFrom": [ + "reward_rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reward_event_id_event_id_fk": { + "name": "reward_event_id_event_id_fk", + "tableFrom": "reward", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.reward_rule": { + "name": "reward_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + } + }, + "indexes": { + "reward_rule_program_id_idx": { + "name": "reward_rule_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_rule_type_idx": { + "name": "reward_rule_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_rule_is_active_idx": { + "name": "reward_rule_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_rule_program_id_program_id_fk": { + "name": "reward_rule_program_id_program_id_fk", + "tableFrom": "reward_rule", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/coredb/drizzle/meta/_journal.json b/packages/coredb/drizzle/meta/_journal.json index 97fffff..90869e2 100644 --- a/packages/coredb/drizzle/meta/_journal.json +++ b/packages/coredb/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1762000000000, "tag": "0002_add_organization_hierarchy", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1762837032467, + "tag": "0003_icy_bedlam", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/coredb/src/schema.ts b/packages/coredb/src/schema.ts index 1008271..fe76c1f 100644 --- a/packages/coredb/src/schema.ts +++ b/packages/coredb/src/schema.ts @@ -32,7 +32,7 @@ export const baseFields = (entityType: string) => { .$defaultFn(() => entityType && isValidEntityType(entityType) ? createId(entityType) - : createCuid() + : createCuid(), ), createdAt: timestamp("created_at") .notNull() @@ -110,8 +110,8 @@ export const orgUser = pgTable("org_user", { role: text("role").notNull(), }); -export const project = pgTable("project", { - ...baseFields("project"), +export const product = pgTable("product", { + ...baseFields("product"), orgId: text("org_id").references(() => org.id, { onDelete: "cascade", }), @@ -126,11 +126,11 @@ export const project = pgTable("project", { onboardingStep: integer("onboarding_step").default(1), }); -export const projectUser = pgTable("project_user", { - ...baseFields("projectUser"), - projectId: text("project_id") +export const productUser = pgTable("product_user", { + ...baseFields("productUser"), + productId: text("product_id") .notNull() - .references(() => project.id, { onDelete: "cascade" }), + .references(() => product.id, { onDelete: "cascade" }), userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), @@ -142,7 +142,7 @@ export const invitation = pgTable("invitation", { organizationId: text("organization_id").references(() => org.id, { onDelete: "cascade", }), - projectId: text("project_id").references(() => project.id, { + productId: text("product_id").references(() => product.id, { onDelete: "cascade", }), email: text("email").notNull(), @@ -191,9 +191,9 @@ export const programTemplate = pgTable("program_template", { export const program = pgTable("program", { ...baseFields("program"), - projectId: text("project_id") + productId: text("product_id") .notNull() - .references(() => project.id, { onDelete: "cascade" }), + .references(() => product.id, { onDelete: "cascade" }), programTemplateId: text("program_template_id") .notNull() .references(() => programTemplate.id, { onDelete: "restrict" }), // Restrict deletion if programs use it @@ -218,14 +218,14 @@ export const participant = pgTable( ...baseFields("participant"), name: text("name"), email: text("email"), - projectId: text("project_id") + productId: text("product_id") .notNull() - .references(() => project.id, { onDelete: "cascade" }), + .references(() => product.id, { onDelete: "cascade" }), externalId: text("external_id"), }, (participant) => ({ - projectExternalUnique: unique().on( - participant.projectId, + productExternalUnique: unique().on( + participant.productId, participant.externalId, ), }), @@ -287,12 +287,12 @@ export const reward = pgTable( }), ); -// Project secrets for JWT generation -export const projectSecrets = pgTable("project_secrets", { - ...baseFields("projectSecrets"), - projectId: text("project_id") +// Product secrets for JWT generation +export const productSecrets = pgTable("product_secrets", { + ...baseFields("productSecrets"), + productId: text("product_id") .notNull() - .references(() => project.id, { onDelete: "cascade" }), + .references(() => product.id, { onDelete: "cascade" }), clientId: text("client_id").notNull(), clientSecret: text("client_secret").notNull(), }); @@ -329,9 +329,9 @@ export const event = pgTable( "event", { ...baseFields("event"), - projectId: text("project_id") + productId: text("product_id") .notNull() - .references(() => project.id, { onDelete: "cascade" }), + .references(() => product.id, { onDelete: "cascade" }), programId: text("program_id").references(() => program.id, { onDelete: "cascade", }), @@ -345,7 +345,7 @@ export const event = pgTable( }, (table) => ({ // Indexes for performance - projectIdIdx: index("event_project_id_idx").on(table.projectId), + productIdIdx: index("event_product_id_idx").on(table.productId), programIdIdx: index("event_program_id_idx").on(table.programId), participantIdIdx: index("event_participant_id_idx").on(table.participantId), referralIdIdx: index("event_referral_id_idx").on(table.referralId), diff --git a/packages/id/README.md b/packages/id/README.md index 58c9d9f..a7f65d0 100644 --- a/packages/id/README.md +++ b/packages/id/README.md @@ -21,70 +21,70 @@ pnpm add @refref/id ### Basic ID Generation ```typescript -import { createId } from '@refref/id'; +import { createId } from "@refref/id"; // Generate a user ID -const userId = createId('user'); +const userId = createId("user"); // Returns: usr_cl9x8k2n000000d0e8y8z8b0w -// Generate a project ID -const projectId = createId('project'); +// Generate a product ID +const productId = createId("product"); // Returns: prj_cm1a9b3c000000e0f9z0a1b2c ``` ### Type Validation ```typescript -import { isValidEntityType, getValidEntityTypes } from '@refref/id'; +import { isValidEntityType, getValidEntityTypes } from "@refref/id"; // Check if an entity type is valid -if (isValidEntityType('user')) { - console.log('Valid entity type'); +if (isValidEntityType("user")) { + console.log("Valid entity type"); } // Get all valid entity types const validTypes = getValidEntityTypes(); console.log(validTypes); -// ['user', 'project', 'program', 'participant', ...] +// ['user', 'product', 'program', 'participant', ...] ``` ### Error Handling ```typescript -import { createId, InvalidEntityError } from '@refref/id'; +import { createId, InvalidEntityError } from "@refref/id"; try { - createId('invalid' as any); + createId("invalid" as any); } catch (error) { if (error instanceof InvalidEntityError) { console.error(error.message); - // Invalid entity type: invalid. Valid types are: user, project, program, ... + // Invalid entity type: invalid. Valid types are: user, product, program, ... } } ``` ## Entity Types & Prefixes -| Entity Type | Prefix | Example ID | -|------------------|--------|------------------------------------| -| user | usr | usr_cl9x8k2n000000d0e8y8z8b0w | -| session | ses | ses_cm1a9b3c000000e0f9z0a1b2c | -| account | acc | acc_cm2b0c4d000000f0g0a2b3c4d | -| verification | ver | ver_cm3c1d5e000000g0h1b3c4d5e | -| project | prj | prj_cm4d2e6f000000h0i2c4d5e6f | -| projectUser | pju | pju_cm5e3f7g000000i0j3d5e6f7g | -| invitation | inv | inv_cm6f4g8h000000j0k4e6f7g8h | -| apikey | key | key_cm7g5h9i000000k0l5f7g8h9i | -| programTemplate | pgt | pgt_cm8h6i0j000000l0m6g8h9i0j | -| program | prg | prg_cm9i7j1k000000m0n7h9i0j1k | -| eventDefinition | evd | evd_cma j8k2l000000n0o8i0j1k2l | -| participant | prt | prt_cmbk9l3m000000o0p9j1k2l3m | -| rewardRule | rwr | rwr_cmcl0m4n000000p0q0k2l3m4n | -| reward | rwd | rwd_cmdm1n5o000000q0r1l3m4n5o | -| projectSecrets | sec | sec_cmen2o6p000000r0s2m4n5o6p | -| referralLink | rfl | rfl_cmfo3p7q000000s0t3n5o6p7q | -| referral | ref | ref_cmgp4q8r000000t0u4o6p7q8r | -| event | evt | evt_cmhq5r9s000000u0v5p7q8r9s | +| Entity Type | Prefix | Example ID | +| --------------- | ------ | ------------------------------ | +| user | usr | usr_cl9x8k2n000000d0e8y8z8b0w | +| session | ses | ses_cm1a9b3c000000e0f9z0a1b2c | +| account | acc | acc_cm2b0c4d000000f0g0a2b3c4d | +| verification | ver | ver_cm3c1d5e000000g0h1b3c4d5e | +| product | prj | prj_cm4d2e6f000000h0i2c4d5e6f | +| productUser | pju | pju_cm5e3f7g000000i0j3d5e6f7g | +| invitation | inv | inv_cm6f4g8h000000j0k4e6f7g8h | +| apikey | key | key_cm7g5h9i000000k0l5f7g8h9i | +| programTemplate | pgt | pgt_cm8h6i0j000000l0m6g8h9i0j | +| program | prg | prg_cm9i7j1k000000m0n7h9i0j1k | +| eventDefinition | evd | evd_cma j8k2l000000n0o8i0j1k2l | +| participant | prt | prt_cmbk9l3m000000o0p9j1k2l3m | +| rewardRule | rwr | rwr_cmcl0m4n000000p0q0k2l3m4n | +| reward | rwd | rwd_cmdm1n5o000000q0r1l3m4n5o | +| productSecrets | sec | sec_cmen2o6p000000r0s2m4n5o6p | +| referralLink | rfl | rfl_cmfo3p7q000000s0t3n5o6p7q | +| referral | ref | ref_cmgp4q8r000000t0u4o6p7q8r | +| event | evt | evt_cmhq5r9s000000u0v5p7q8r9s | ## API @@ -118,27 +118,27 @@ Custom error class thrown when an invalid entity type is provided to `createId() Use with the `baseFields` function in your schema: ```typescript -import { createId, isValidEntityType } from '@refref/id'; -import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { createId, isValidEntityType } from "@refref/id"; +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; const baseFields = (entityType: string) => ({ - id: text('id') + id: text("id") .primaryKey() .$defaultFn(() => entityType && isValidEntityType(entityType) ? createId(entityType) - : createId() + : createId(), ), - createdAt: timestamp('created_at', { withTimezone: true }) + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }) + updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), }); -export const user = pgTable('user', { - ...baseFields('user'), - email: text('email').notNull(), +export const user = pgTable("user", { + ...baseFields("user"), + email: text("email").notNull(), }); ``` diff --git a/packages/id/src/index.test.ts b/packages/id/src/index.test.ts index 47b5e79..c13b4db 100644 --- a/packages/id/src/index.test.ts +++ b/packages/id/src/index.test.ts @@ -1,115 +1,119 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vitest"; import { createId, getValidEntityTypes, InvalidEntityError, isValidEntityType, type EntityType, -} from './index'; +} from "./index"; -describe('@refref/id', () => { - describe('createId', () => { - it('should create IDs with correct format', () => { - const id = createId('user'); +describe("@refref/id", () => { + describe("createId", () => { + it("should create IDs with correct format", () => { + const id = createId("user"); // Format: prefix_cuid2 where cuid2 is 24 chars expect(id).toMatch(/^usr_[a-z0-9]{24}$/); }); - it('should create unique IDs', () => { + it("should create unique IDs", () => { const ids = new Set(); const iterations = 1000; for (let i = 0; i < iterations; i++) { - ids.add(createId('user')); + ids.add(createId("user")); } expect(ids.size).toBe(iterations); }); - it('should throw InvalidEntityError for invalid entity types', () => { - expect(() => createId('invalid' as EntityType)).toThrow(InvalidEntityError); - expect(() => createId('unknown' as EntityType)).toThrow(InvalidEntityError); + it("should throw InvalidEntityError for invalid entity types", () => { + expect(() => createId("invalid" as EntityType)).toThrow( + InvalidEntityError, + ); + expect(() => createId("unknown" as EntityType)).toThrow( + InvalidEntityError, + ); }); - describe('entity type prefixes', () => { + describe("entity type prefixes", () => { const entityTests: [EntityType, string][] = [ - ['user', 'usr'], - ['session', 'ses'], - ['account', 'acc'], - ['verification', 'ver'], - ['project', 'prj'], - ['projectUser', 'pju'], - ['invitation', 'inv'], - ['apikey', 'key'], - ['programTemplate', 'pgt'], - ['program', 'prg'], - ['eventDefinition', 'evd'], - ['participant', 'prt'], - ['rewardRule', 'rwr'], - ['reward', 'rwd'], - ['projectSecrets', 'sec'], - ['referralLink', 'rfl'], - ['referral', 'ref'], - ['event', 'evt'], + ["user", "usr"], + ["session", "ses"], + ["account", "acc"], + ["verification", "ver"], + ["product", "prd"], + ["productUser", "pu"], + ["invitation", "inv"], + ["apikey", "key"], + ["programTemplate", "pgt"], + ["program", "prg"], + ["eventDefinition", "evd"], + ["participant", "prt"], + ["rewardRule", "rwr"], + ["reward", "rwd"], + ["productSecrets", "sec"], + ["referralLink", "rfl"], + ["referral", "ref"], + ["event", "evt"], ]; it.each(entityTests)( - 'should create %s ID with %s prefix', + "should create %s ID with %s prefix", (entityType, expectedPrefix) => { const id = createId(entityType); expect(id).toMatch(new RegExp(`^${expectedPrefix}_[a-z0-9]{24}$`)); - } + }, ); }); }); - describe('isValidEntityType', () => { - it('should return true for valid entity types', () => { - expect(isValidEntityType('user')).toBe(true); - expect(isValidEntityType('project')).toBe(true); - expect(isValidEntityType('program')).toBe(true); - expect(isValidEntityType('reward')).toBe(true); + describe("isValidEntityType", () => { + it("should return true for valid entity types", () => { + expect(isValidEntityType("user")).toBe(true); + expect(isValidEntityType("product")).toBe(true); + expect(isValidEntityType("program")).toBe(true); + expect(isValidEntityType("reward")).toBe(true); }); - it('should return false for invalid entity types', () => { - expect(isValidEntityType('invalid')).toBe(false); - expect(isValidEntityType('unknown')).toBe(false); - expect(isValidEntityType('')).toBe(false); - expect(isValidEntityType('User')).toBe(false); + it("should return false for invalid entity types", () => { + expect(isValidEntityType("invalid")).toBe(false); + expect(isValidEntityType("unknown")).toBe(false); + expect(isValidEntityType("")).toBe(false); + expect(isValidEntityType("User")).toBe(false); }); }); - describe('getValidEntityTypes', () => { - it('should return an array of all valid entity types', () => { + describe("getValidEntityTypes", () => { + it("should return an array of all valid entity types", () => { const validTypes = getValidEntityTypes(); expect(Array.isArray(validTypes)).toBe(true); expect(validTypes.length).toBeGreaterThan(0); - expect(validTypes).toContain('user'); - expect(validTypes).toContain('project'); - expect(validTypes).toContain('program'); + expect(validTypes).toContain("user"); + expect(validTypes).toContain("product"); + expect(validTypes).toContain("program"); }); - it('should return all 18 entity types', () => { + it("should return all 18 entity types", () => { const validTypes = getValidEntityTypes(); expect(validTypes.length).toBe(18); }); }); - describe('InvalidEntityError', () => { - it('should have correct error message', () => { - const error = new InvalidEntityError('invalid'); + describe("InvalidEntityError", () => { + it("should have correct error message", () => { + const error = new InvalidEntityError("invalid"); - expect(error.message).toContain('Invalid entity type: invalid'); - expect(error.message).toContain('Valid types are:'); - expect(error.name).toBe('InvalidEntityError'); + expect(error.message).toContain("Invalid entity type: invalid"); + expect(error.message).toContain("Valid types are:"); + expect(error.name).toBe("InvalidEntityError"); }); - it('should list all valid entity types in error message', () => { - const error = new InvalidEntityError('test'); + it("should list all valid entity types in error message", () => { + const error = new InvalidEntityError("test"); const validTypes = getValidEntityTypes(); - validTypes.forEach(type => { + validTypes.forEach((type) => { expect(error.message).toContain(type); }); }); diff --git a/packages/id/src/index.ts b/packages/id/src/index.ts index 8784b52..c17dae2 100644 --- a/packages/id/src/index.ts +++ b/packages/id/src/index.ts @@ -1,27 +1,27 @@ -import { createId as createCuid } from '@paralleldrive/cuid2'; +import { createId as createCuid } from "@paralleldrive/cuid2"; /** * Entity type prefixes for RefRef domain models */ const ENTITY_PREFIXES = { - user: 'usr', - session: 'ses', - account: 'acc', - verification: 'ver', - project: 'prj', - projectUser: 'pju', - invitation: 'inv', - apikey: 'key', - programTemplate: 'pgt', - program: 'prg', - eventDefinition: 'evd', - participant: 'prt', - rewardRule: 'rwr', - reward: 'rwd', - projectSecrets: 'sec', - referralLink: 'rfl', - referral: 'ref', - event: 'evt', + user: "usr", + session: "ses", + account: "acc", + verification: "ver", + product: "prd", + productUser: "pu", + invitation: "inv", + apikey: "key", + programTemplate: "pgt", + program: "prg", + eventDefinition: "evd", + participant: "prt", + rewardRule: "rwr", + reward: "rwd", + productSecrets: "sec", + referralLink: "rfl", + referral: "ref", + event: "evt", } as const; /** @@ -35,9 +35,9 @@ export type EntityType = keyof typeof ENTITY_PREFIXES; export class InvalidEntityError extends Error { constructor(entityType: string) { super( - `Invalid entity type: ${entityType}. Valid types are: ${Object.keys(ENTITY_PREFIXES).join(', ')}` + `Invalid entity type: ${entityType}. Valid types are: ${Object.keys(ENTITY_PREFIXES).join(", ")}`, ); - this.name = 'InvalidEntityError'; + this.name = "InvalidEntityError"; } } @@ -50,7 +50,7 @@ export class InvalidEntityError extends Error { * * @example * createId('user') // Returns: usr_cl9x8k2n000000d0e8y8z8b0w - * createId('project') // Returns: prj_cm1a9b3c000000e0f9z0a1b2c + * createId('product') // Returns: prj_cm1a9b3c000000e0f9z0a1b2c */ export function createId(entityType: EntityType): string { const prefix = ENTITY_PREFIXES[entityType]; @@ -72,7 +72,9 @@ export function createId(entityType: EntityType): string { * isValidEntityType('user') // Returns: true * isValidEntityType('invalid') // Returns: false */ -export function isValidEntityType(entityType: string): entityType is EntityType { +export function isValidEntityType( + entityType: string, +): entityType is EntityType { return entityType in ENTITY_PREFIXES; } @@ -82,7 +84,7 @@ export function isValidEntityType(entityType: string): entityType is EntityType * @returns Array of valid entity types * * @example - * getValidEntityTypes() // Returns: ['user', 'project', 'program', ...] + * getValidEntityTypes() // Returns: ['user', 'product', 'program', ...] */ export function getValidEntityTypes(): EntityType[] { return Object.keys(ENTITY_PREFIXES) as EntityType[]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d83ed36..ad4d626 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -23,7 +23,7 @@ export type WidgetInitResponseType = WidgetConfigType; export interface WidgetStore { initialized: boolean; token: string | null | undefined; - projectId: string | null | undefined; + productId: string | null | undefined; widgetElementSelector: string | null | undefined; isOpen: boolean; config: WidgetConfigType; @@ -60,12 +60,12 @@ export const jwtPayloadSchema = z.object({ sub: z.string(), email: z.string().email().optional(), name: z.string().optional(), - projectId: z.string(), + productId: z.string(), }); export type JwtPayloadType = z.infer; export const widgetInitRequestSchema = z.object({ - projectId: z.string(), + productId: z.string(), referralCode: z.string().optional(), }); export type WidgetInitRequestType = z.infer; diff --git a/packages/ui/src/components/referral-widget/referral-widget-dialog-content.tsx b/packages/ui/src/components/referral-widget/referral-widget-dialog-content.tsx index fbc4c61..904e544 100644 --- a/packages/ui/src/components/referral-widget/referral-widget-dialog-content.tsx +++ b/packages/ui/src/components/referral-widget/referral-widget-dialog-content.tsx @@ -79,11 +79,7 @@ export function ReferralWidgetContent({ Your Referral Link
- +