Stands up packages/api so the swipe path stops trusting its caller, and puts the auth library behind an interface we own. - packages/api: tRPC v11 with a Session type WE define, not one re-exported from an auth library. Swapping providers means rewriting one SessionResolver, not touching a router. - Procedure layers: public / protected / client / pro / verifiedPro / admin. Admin routes 404 rather than 403 so they cannot be probed. - deck router replaces the untrusted server action. Ownership is checked on every operation and returns NOT_FOUND, never FORBIDDEN, so job ids cannot be enumerated. 23 tests, mostly authorization. - packages/storage: presigned direct-to-R2 uploads. The server picks the key, so a caller can only write under their own user id. 14 tests. Three defects found and fixed: - The lazy db Proxy failed drizzle's is(db, PgDatabase) because it did not trap getPrototypeOf. Auth adapters dispatch on exactly that check, so this would have failed at runtime inside third-party code. Fixed and pinned with a regression test. - The open-request cap was a read-then-write race: concurrent swipes could both read 4 and both insert. Now one transaction with the job row locked. The cap is checked before the tombstone is written, so a rejected swipe leaves no trace and the card stays on the deck. - superjson was configured in two of the three required places. Without the QueryClient dehydrate/hydrate pair, RSC-prefetched data arrives as a raw envelope with no type error to warn you. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
271 lines
9.4 KiB
TypeScript
271 lines
9.4 KiB
TypeScript
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,
|
|
};
|
|
}),
|
|
});
|