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>
449 lines
16 KiB
TypeScript
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 };
|
|
}),
|
|
});
|