Files
linkder/packages/api/src/routers/user.ts
T
serfaandClaude Opus 5 1808ad4cba Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:56:31 -04:00

449 lines
16 KiB
TypeScript

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 };
}),
});