import { TRPCError } from '@trpc/server'; import { and, eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; import { schema } from '@linkder/db'; import { assertTransition, credentialSchema, proProfileSchema } from '@linkder/shared'; import { protectedProcedure, proProcedure, router } from '../trpc'; /** * A pro is only shown to clients once verification passes. These procedures * cover everything up to that point: building the profile, uploading documents, * and submitting for review. None of them require `verified` — that would make * onboarding impossible. */ export const proRouter = router({ /** The caller's own pro profile, with everything the onboarding wizard needs. */ me: proProcedure.query(async ({ ctx }) => { const profile = await ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, ctx.session.userId), }); if (!profile) return null; const [categories, media, credentials] = await Promise.all([ ctx.db .select({ categoryId: schema.proCategories.categoryId }) .from(schema.proCategories) .where(eq(schema.proCategories.proId, ctx.session.userId)), ctx.db .select() .from(schema.proMedia) .where(eq(schema.proMedia.proId, ctx.session.userId)) .orderBy(schema.proMedia.position), ctx.db .select() .from(schema.credentials) .where(eq(schema.credentials.proId, ctx.session.userId)), ]); return { ...profile, categoryIds: categories.map((c) => c.categoryId), media, credentials, }; }), /** * Create or replace the profile. Idempotent so the wizard can save on every * step without the client tracking whether a row exists yet. */ upsertProfile: proProcedure.input(proProfileSchema).mutation(async ({ ctx, input }) => { const validCategories = await ctx.db .select({ id: schema.categories.id }) .from(schema.categories) .where( and( inArray(schema.categories.id, input.categoryIds), eq(schema.categories.isActive, true), ), ); if (validCategories.length !== input.categoryIds.length) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'One of those trades is not available' }); } const existing = await ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, ctx.session.userId), }); // Editing a live profile sends it back for review — a verified plumber must // not be able to quietly become an unverified electrician. const materiallyChanged = existing && (existing.serviceRadiusM !== input.serviceRadiusM || existing.baseLocation.lat !== input.location.lat || existing.baseLocation.lng !== input.location.lng); await ctx.db.transaction(async (tx) => { const values = { userId: ctx.session.userId, headline: input.headline, bio: input.bio, hourlyRateCents: input.hourlyRateCents, yearsExperience: input.yearsExperience, baseLocation: input.location, serviceRadiusM: input.serviceRadiusM, updatedAt: new Date(), }; await tx .insert(schema.proProfiles) .values(values) .onConflictDoUpdate({ target: schema.proProfiles.userId, set: values }); await tx .delete(schema.proCategories) .where(eq(schema.proCategories.proId, ctx.session.userId)); await tx.insert(schema.proCategories).values( input.categoryIds.map((categoryId) => ({ proId: ctx.session.userId, categoryId })), ); }); return { saved: true, requiresReReview: Boolean(materiallyChanged) }; }), /** Attach an uploaded photo. The file itself went straight to R2. */ addMedia: proProcedure .input( z.object({ url: z.string().url(), kind: z.enum(['photo', 'work_sample']).default('photo'), position: z.number().int().min(0).max(20).optional(), }), ) .mutation(async ({ ctx, input }) => { const existing = await ctx.db .select() .from(schema.proMedia) .where(eq(schema.proMedia.proId, ctx.session.userId)); if (existing.length >= 10) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'You can have at most 10 photos' }); } const [media] = await ctx.db .insert(schema.proMedia) .values({ proId: ctx.session.userId, url: input.url, kind: input.kind, position: input.position ?? existing.length, }) .returning(); return media; }), removeMedia: proProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { // Scoped to the caller so one pro cannot delete another's photo. const deleted = await ctx.db .delete(schema.proMedia) .where(and(eq(schema.proMedia.id, input.id), eq(schema.proMedia.proId, ctx.session.userId))) .returning(); if (deleted.length === 0) throw new TRPCError({ code: 'NOT_FOUND' }); return { removed: true }; }), /** Upload a licence, insurance certificate or ID document for review. */ addCredential: proProcedure.input(credentialSchema).mutation(async ({ ctx, input }) => { const [credential] = await ctx.db .insert(schema.credentials) .values({ proId: ctx.session.userId, kind: input.kind, fileUrl: input.fileUrl, issuer: input.issuer ?? null, expiresAt: input.expiresAt ?? null, }) .returning(); return credential; }), /** * Submit for review. Deliberately strict about what "complete" means — a * half-finished profile reaching the admin queue wastes a reviewer's time, * and the reviewer is the expensive part of this pipeline. */ submitForReview: proProcedure.mutation(async ({ ctx }) => { const profile = await ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, ctx.session.userId), }); if (!profile) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Fill in your profile first' }); } const [categories, media, credentials] = await Promise.all([ ctx.db .select() .from(schema.proCategories) .where(eq(schema.proCategories.proId, ctx.session.userId)), ctx.db.select().from(schema.proMedia).where(eq(schema.proMedia.proId, ctx.session.userId)), ctx.db .select() .from(schema.credentials) .where(eq(schema.credentials.proId, ctx.session.userId)), ]); const missing: string[] = []; if (categories.length === 0) missing.push('at least one trade'); if (media.length === 0) missing.push('at least one photo'); if (!credentials.some((c) => c.kind === 'id')) missing.push('a photo ID'); if (!credentials.some((c) => c.kind === 'insurance')) missing.push('proof of insurance'); if (missing.length) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: `Before we can review your account we still need: ${missing.join(', ')}.`, }); } assertTransition('verification', profile.verificationStatus, 'pending'); await ctx.db .update(schema.proProfiles) .set({ verificationStatus: 'pending', updatedAt: new Date() }) .where(eq(schema.proProfiles.userId, ctx.session.userId)); await ctx.db.insert(schema.auditLog).values({ actorId: ctx.session.userId, action: 'verification.submitted', entity: 'pro_profile', entityId: ctx.session.userId, ip: ctx.ip, }); // TODO(M2): kick off the Didit identity session and notify the admin queue. return { status: 'pending' as const }; }), /** Holiday mode. Keeps a verified pro off the deck without unverifying them. */ setAcceptingJobs: proProcedure .input(z.object({ accepting: z.boolean() })) .mutation(async ({ ctx, input }) => { await ctx.db .update(schema.proProfiles) .set({ isAcceptingJobs: input.accepting, updatedAt: new Date() }) .where(eq(schema.proProfiles.userId, ctx.session.userId)); return { accepting: input.accepting }; }), /** * Public profile, for the card detail view. Only ever returns a verified pro, * and deliberately omits anything private — no phone, no documents, no address. */ publicProfile: protectedProcedure .input(z.object({ proId: z.string().uuid() })) .query(async ({ ctx, input }) => { const profile = await ctx.db.query.proProfiles.findFirst({ where: and( eq(schema.proProfiles.userId, input.proId), eq(schema.proProfiles.verificationStatus, 'verified'), ), }); if (!profile) throw new TRPCError({ code: 'NOT_FOUND' }); const [user] = await ctx.db .select({ name: schema.users.name, image: schema.users.image }) .from(schema.users) .where(eq(schema.users.id, input.proId)); const media = await ctx.db .select() .from(schema.proMedia) .where(eq(schema.proMedia.proId, input.proId)) .orderBy(schema.proMedia.position); return { proId: profile.userId, name: user?.name ?? null, image: user?.image ?? null, headline: profile.headline, bio: profile.bio, hourlyRateCents: profile.hourlyRateCents, yearsExperience: profile.yearsExperience, ratingAvg: profile.ratingAvg === null ? null : Number(profile.ratingAvg), ratingCount: profile.ratingCount, completedJobs: profile.completedJobs, media, }; }), });