import { randomUUID } from 'node:crypto'; import { TRPCError } from '@trpc/server'; import { and, desc, eq, isNull } from 'drizzle-orm'; import { z } from 'zod'; import { schema } from '@linkdr/db'; import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkdr/shared'; import { resolveLocation } from '../location'; import { protectedProcedure, publicProcedure, router } from '../trpc'; export const userRouter = router({ /** Who am I — the shape the client needs to decide what to render. */ me: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.query.users.findFirst({ where: eq(schema.users.id, ctx.session.userId), columns: { id: true, name: true, email: true, phoneNumber: true, image: true, role: true, createdAt: true, }, }); if (!user) throw new TRPCError({ code: 'NOT_FOUND' }); const hasProProfile = user.role === 'pro' ? Boolean( await ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, user.id), columns: { userId: true }, }), ) : false; return { ...user, // A synthetic address is not a real inbox; the UI must not offer to email them. hasContactableEmail: isContactableEmail(user.email), verificationStatus: ctx.session.verificationStatus, hasProProfile, }; }), /** * Choose client or pro. * * Google signup lands everyone on the `client` default, so a tradesperson has * to be able to say otherwise. Deliberately one-way once there is anything * attached: switching a pro back to client would orphan their profile, * reviews and payout account, and switching a client to pro mid-job would * strand the jobs they already posted. * * `admin` is never settable here — it is granted out of band. */ setRole: protectedProcedure .input(z.object({ role: z.enum(['client', 'pro']) })) .mutation(async ({ ctx, input }) => { if (ctx.session.role === input.role) return { role: input.role, changed: false }; if (ctx.session.role === 'admin') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Admins cannot change their own role' }); } if (ctx.session.role === 'pro') { const profile = await ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, ctx.session.userId), columns: { userId: true }, }); if (profile) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Your pro profile is already set up. Contact support to change account type.', }); } } if (ctx.session.role === 'client') { const jobs = await ctx.db.query.jobs.findFirst({ where: eq(schema.jobs.clientId, ctx.session.userId), columns: { id: true }, }); if (jobs) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'You have already posted a job, so this account stays a customer account.', }); } } await ctx.db .update(schema.users) .set({ role: input.role, updatedAt: new Date() }) .where(eq(schema.users.id, ctx.session.userId)); await ctx.db.insert(schema.auditLog).values({ actorId: ctx.session.userId, action: 'user.role_changed', entity: 'user', entityId: ctx.session.userId, metadata: { from: ctx.session.role, to: input.role }, ip: ctx.ip, }); return { role: input.role, changed: true }; }), /** * Request a real email address. * * Required for pros — they need payout statements, tax records and dispute * notices, none of which can go to a synthetic phone address. * * The address is NOT written to `users.email` here. It is parked in * email_change_requests until the token comes back, because `users.email` is * UNIQUE: writing an unproven address would let anyone type a stranger's * address and permanently block that stranger from signing up with Google. * * For the same reason this never reports that an address is already taken — * that answer is an "is this person registered?" oracle. A collision is * detected at confirm time, once ownership is proven. */ requestEmailChange: protectedProcedure .input(z.object({ email: z.string().email() })) .mutation(async ({ ctx, input }) => { const email = input.email.trim().toLowerCase(); if (!isContactableEmail(email)) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'That address is not one we can send to.', }); } const token = randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, ''); await ctx.db.insert(schema.emailChangeRequests).values({ userId: ctx.session.userId, email, token, expiresAt: new Date(Date.now() + 24 * 3_600_000), }); // The caller learns only that we tried. Whether the address exists, is // deliverable, or already belongs to someone else stays unobservable. return { sent: true, token }; }), /** * Prove ownership and commit the address. * * Public rather than protected: the link is opened from an inbox, which may * well be a different device with no session. The token is the credential. */ confirmEmailChange: publicProcedure .input(z.object({ token: z.string().min(32) })) .mutation(async ({ ctx, input }) => { const request = await ctx.db.query.emailChangeRequests.findFirst({ where: eq(schema.emailChangeRequests.token, input.token), }); if (!request || request.consumedAt || request.expiresAt < new Date()) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'That link is no longer valid.' }); } // Checked here, not at request time: by now ownership is proven, so // reporting the collision tells the real owner something true about their // own address rather than leaking someone else's. const taken = await ctx.db.query.users.findFirst({ where: eq(schema.users.email, request.email), columns: { id: true }, }); if (taken && taken.id !== request.userId) { throw new TRPCError({ code: 'CONFLICT', message: 'Another account already uses that email address.', }); } await ctx.db.transaction(async (tx) => { await tx .update(schema.users) .set({ email: request.email, emailVerified: true, updatedAt: new Date() }) .where(eq(schema.users.id, request.userId)); await tx .update(schema.emailChangeRequests) .set({ consumedAt: new Date() }) .where(eq(schema.emailChangeRequests.id, request.id)); }); return { email: request.email }; }), /** Name and avatar. Everything else on the account has its own procedure. */ updateProfile: protectedProcedure .input( z.object({ name: z.string().trim().min(1).max(80).optional(), image: z.string().url().max(500).nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { if (input.name === undefined && input.image === undefined) return { updated: false }; await ctx.db .update(schema.users) .set({ ...(input.name !== undefined ? { name: input.name } : {}), ...(input.image !== undefined ? { image: input.image } : {}), updatedAt: new Date(), }) .where(eq(schema.users.id, ctx.session.userId)); return { updated: true }; }), /** * "Where am I, and how far am I looking?" * * Reads from whichever table actually decides matching for this caller. A * verified pro is matched on `pro_profiles.base_location` + `service_radius_m` * — showing them `users.search_radius_m` instead would be a settings screen * that displays a number nothing acts on. */ location: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.query.users.findFirst({ where: eq(schema.users.id, ctx.session.userId), columns: { location: true, locationText: true, searchRadiusM: true }, }); if (!user) throw new TRPCError({ code: 'NOT_FOUND' }); const profile = ctx.session.role === 'pro' ? await ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, ctx.session.userId), columns: { baseLocation: true, serviceRadiusM: true, verificationStatus: true }, }) : undefined; return { /** Which record this screen is editing — the copy differs, and so does the effect. */ scope: profile ? ('pro' as const) : ('client' as const), /** A pro who has not finished onboarding has no service area to edit yet. */ needsProfile: ctx.session.role === 'pro' && !profile, location: profile ? profile.baseLocation : user.location, addressText: user.locationText, radiusM: profile ? profile.serviceRadiusM : user.searchRadiusM, /** Warn before the save, not after: this is what costs them their badge. */ reviewOnChange: profile?.verificationStatus === 'verified', }; }), /** * Move the pin, or change the range. * * Routed by role for the reason above. For a pro this is the same material * change as editing the area in the wizard, so it carries the same * consequence — a verified profile goes back to pending. Doing anything else * would make settings the way around verification. */ updateLocation: protectedProcedure .input(updateLocationSchema) .mutation(async ({ ctx, input }) => { const profile = ctx.session.role === 'pro' ? await ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, ctx.session.userId), }) : undefined; // The label is no longer a free-text field stored beside unrelated // coordinates: it is whatever the geocoder called the point we resolved, // so the two cannot drift apart. const resolved = input.place ? await resolveLocation(input.place) : null; if (resolved) { await ctx.db .update(schema.users) .set({ locationText: resolved.addressText, updatedAt: new Date() }) .where(eq(schema.users.id, ctx.session.userId)); } if (!profile) { if (resolved || input.radiusM !== undefined) { await ctx.db .update(schema.users) .set({ ...(resolved ? { location: resolved.location, locationPrecision: resolved.precision, locationPlaceId: resolved.placeId, } : {}), ...(input.radiusM !== undefined ? { searchRadiusM: input.radiusM } : {}), updatedAt: new Date(), }) .where(eq(schema.users.id, ctx.session.userId)); } return { scope: 'client' as const, sentForReview: false }; } const moved = resolved !== null && (resolved.location.lat !== profile.baseLocation.lat || resolved.location.lng !== profile.baseLocation.lng); const resized = input.radiusM !== undefined && input.radiusM !== profile.serviceRadiusM; // Only a currently-verified pro needs demoting: a draft or pending profile // is not on the deck anyway, and demoting a suspended one would quietly // undo a moderator. const sendBackForReview = (moved || resized) && profile.verificationStatus === 'verified'; if (sendBackForReview) assertTransition('verification', 'verified', 'pending'); if (moved || resized) { await ctx.db.transaction(async (tx) => { await tx .update(schema.proProfiles) .set({ ...(resolved ? { baseLocation: resolved.location, baseLocationPrecision: resolved.precision, baseLocationPlaceId: resolved.placeId, } : {}), ...(input.radiusM !== undefined ? { serviceRadiusM: input.radiusM } : {}), ...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}), updatedAt: new Date(), }) .where(eq(schema.proProfiles.userId, ctx.session.userId)); if (sendBackForReview) { await tx.insert(schema.auditLog).values({ actorId: ctx.session.userId, action: 'verification.re_review_required', entity: 'pro_profile', entityId: ctx.session.userId, metadata: { reason: 'service_area_changed', via: 'settings' }, ip: ctx.ip, }); } }); } return { scope: 'pro' as const, sentForReview: sendBackForReview }; }), /** Signed-in devices, for the security screen. Never another user's. */ sessions: protectedProcedure.query(async ({ ctx }) => { const rows = await ctx.db .select({ id: schema.sessions.id, userAgent: schema.sessions.userAgent, ipAddress: schema.sessions.ipAddress, createdAt: schema.sessions.createdAt, expiresAt: schema.sessions.expiresAt, impersonatedBy: schema.sessions.impersonatedBy, }) .from(schema.sessions) .where(eq(schema.sessions.userId, ctx.session.userId)) .orderBy(desc(schema.sessions.createdAt)); // sessions.token is a live bearer credential and is deliberately not selected. return rows.map((r) => ({ ...r, isImpersonated: r.impersonatedBy !== null })); }), /** * GDPR access request: everything we hold about the caller, as JSON. * Scoped by userId throughout — this must never become a way to read * someone else's rows. */ exportData: protectedProcedure.query(async ({ ctx }) => { const uid = ctx.session.userId; const [user, jobs, swipes, requests, proProfile, preferences] = await Promise.all([ ctx.db.query.users.findFirst({ where: eq(schema.users.id, uid), columns: { id: true, name: true, email: true, phoneNumber: true, image: true, role: true, location: true, locationText: true, searchRadiusM: true, createdAt: true, }, }), ctx.db.select().from(schema.jobs).where(eq(schema.jobs.clientId, uid)), ctx.db.select().from(schema.swipes).where(eq(schema.swipes.proId, uid)), ctx.db.select().from(schema.requests).where(eq(schema.requests.proId, uid)), ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, uid) }), ctx.db.query.notificationPreferences.findFirst({ where: eq(schema.notificationPreferences.userId, uid), }), ]); return { exportedAt: new Date().toISOString(), user, proProfile: proProfile ?? null, jobs, swipes, requests, notificationPreferences: preferences ?? null, }; }), /** * GDPR erasure request. * * Records the ask rather than deleting: bookings, payments and reviews carry * foreign keys and statutory retention periods, so a cascade would destroy * records we are required to keep. A human actions this. It needs a real * anonymise-and-retain flow before there are real users. */ requestDeletion: protectedProcedure .input(z.object({ reason: z.string().max(1000).optional() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.query.deletionRequests.findFirst({ where: and( eq(schema.deletionRequests.userId, ctx.session.userId), isNull(schema.deletionRequests.actionedAt), ), }); if (existing) return { requested: true, alreadyPending: true }; await ctx.db.insert(schema.deletionRequests).values({ userId: ctx.session.userId, reason: input.reason ?? null, }); await ctx.db.insert(schema.auditLog).values({ actorId: ctx.session.userId, action: 'user.deletion_requested', entity: 'user', entityId: ctx.session.userId, ip: ctx.ip, }); return { requested: true, alreadyPending: false }; }), });