M1 (partial): tRPC API layer, storage, and three real fixes
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>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import type { Db } from '@linkder/db';
|
||||
import type { Role, VerificationStatus } from '@linkder/shared';
|
||||
|
||||
/**
|
||||
* The session shape the API depends on.
|
||||
*
|
||||
* This is deliberately OUR type, not one re-exported from an auth library.
|
||||
* Everything downstream — every procedure, every authorization check — is written
|
||||
* against this interface, so swapping the auth provider is a matter of writing a
|
||||
* new `SessionResolver` rather than touching a single router.
|
||||
*/
|
||||
export interface Session {
|
||||
userId: string;
|
||||
role: Role;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
/** Only present for pros. Gates the procedures that require a live, verified pro. */
|
||||
verificationStatus: VerificationStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a session from an incoming request.
|
||||
*
|
||||
* The web app implements this with its auth library's server-side helper; tests
|
||||
* implement it by returning a fixed session. It must return `null` rather than
|
||||
* throw when the caller is anonymous — an unauthenticated request is normal.
|
||||
*/
|
||||
export type SessionResolver = (req: Request) => Promise<Session | null>;
|
||||
|
||||
export interface CreateContextOptions {
|
||||
req: Request;
|
||||
db: Db;
|
||||
resolveSession: SessionResolver;
|
||||
/** Caller IP, for rate limiting and the audit log. */
|
||||
ip?: string | null;
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
db: Db;
|
||||
session: Session | null;
|
||||
req: Request;
|
||||
ip: string | null;
|
||||
}
|
||||
|
||||
export async function createContext(opts: CreateContextOptions): Promise<Context> {
|
||||
const session = await opts.resolveSession(opts.req);
|
||||
return {
|
||||
db: opts.db,
|
||||
session,
|
||||
req: opts.req,
|
||||
ip: opts.ip ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for a server-side call with no HTTP request — a React Server Component
|
||||
* calling the router directly, or a background worker. Skips the resolver and
|
||||
* takes the session (or null) as given.
|
||||
*/
|
||||
export function createInnerContext(opts: {
|
||||
db: Db;
|
||||
session: Session | null;
|
||||
ip?: string | null;
|
||||
}): Context {
|
||||
return {
|
||||
db: opts.db,
|
||||
session: opts.session,
|
||||
// Routers must never depend on `req` for anything but header access; this
|
||||
// placeholder keeps the type honest for direct server-side calls.
|
||||
req: new Request('http://internal.invalid/rsc'),
|
||||
ip: opts.ip ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { appRouter, type AppRouter } from './root';
|
||||
export {
|
||||
createContext,
|
||||
createInnerContext,
|
||||
type Context,
|
||||
type CreateContextOptions,
|
||||
type Session,
|
||||
type SessionResolver,
|
||||
} from './context';
|
||||
export {
|
||||
createCallerFactory,
|
||||
router,
|
||||
publicProcedure,
|
||||
protectedProcedure,
|
||||
clientProcedure,
|
||||
proProcedure,
|
||||
verifiedProProcedure,
|
||||
adminProcedure,
|
||||
} from './trpc';
|
||||
@@ -0,0 +1,18 @@
|
||||
import { router } from './trpc';
|
||||
import { deckRouter } from './routers/deck';
|
||||
import { jobRouter } from './routers/job';
|
||||
import { proRouter } from './routers/pro';
|
||||
import { uploadRouter } from './routers/upload';
|
||||
|
||||
/**
|
||||
* The API surface. A future React Native app imports `AppRouter` from this
|
||||
* package and gets the entire typed contract with no duplication.
|
||||
*/
|
||||
export const appRouter = router({
|
||||
job: jobRouter,
|
||||
deck: deckRouter,
|
||||
pro: proRouter,
|
||||
upload: uploadRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -0,0 +1,177 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, count, eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { getDeck, getDeckCount, schema } from '@linkder/db';
|
||||
import {
|
||||
DECK_PAGE_SIZE,
|
||||
MAX_OPEN_REQUESTS_PER_JOB,
|
||||
REQUEST_TTL_HOURS,
|
||||
swipeSchema,
|
||||
} from '@linkder/shared';
|
||||
import { clientProcedure, router } from '../trpc';
|
||||
import type { Context } from '../context';
|
||||
|
||||
/**
|
||||
* Loads a job and proves the caller is allowed to act on it.
|
||||
*
|
||||
* Every deck operation goes through this. The previous implementation was a Next
|
||||
* server action that took a jobId and trusted it, which meant anyone could swipe
|
||||
* on anyone else's job — and, once payments land, spend against it.
|
||||
*/
|
||||
async function requireOwnedJob(ctx: Context, jobId: string) {
|
||||
const job = await ctx.db.query.jobs.findFirst({ where: eq(schema.jobs.id, jobId) });
|
||||
if (!job) throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' });
|
||||
|
||||
const session = ctx.session;
|
||||
if (!session) throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
|
||||
if (job.clientId !== session.userId && session.role !== 'admin') {
|
||||
// 404 rather than 403: a stranger should not be able to confirm the job exists.
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' });
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
export const deckRouter = router({
|
||||
/** The next cards for one of the caller's own jobs. */
|
||||
list: clientProcedure
|
||||
.input(z.object({ jobId: z.string().uuid(), limit: z.number().int().min(1).max(50).optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await requireOwnedJob(ctx, input.jobId);
|
||||
const [cards, remaining] = await Promise.all([
|
||||
getDeck(ctx.db, { jobId: input.jobId, limit: input.limit ?? DECK_PAGE_SIZE }),
|
||||
getDeckCount(ctx.db, input.jobId),
|
||||
]);
|
||||
return { cards, remaining };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Record a swipe.
|
||||
*
|
||||
* Left is a tombstone that keeps the pro off this job's deck. Right also sends
|
||||
* the job to the pro as a pending request, subject to the open-request cap —
|
||||
* the cap is what stops one client spraying every plumber in the city and
|
||||
* burning the supply side's goodwill.
|
||||
*/
|
||||
swipe: clientProcedure.input(swipeSchema).mutation(async ({ ctx, input }) => {
|
||||
const job = await requireOwnedJob(ctx, input.jobId);
|
||||
|
||||
if (job.status !== 'open' && job.status !== 'matched') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'This job is no longer taking offers',
|
||||
});
|
||||
}
|
||||
|
||||
// A client cannot send their own job to themselves.
|
||||
if (input.proId === ctx.session.userId) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'You cannot hire yourself' });
|
||||
}
|
||||
|
||||
// The pro must exist, be verified and be open for work. Checking here as well
|
||||
// as in the deck query stops a crafted request from reaching an unverified pro.
|
||||
const pro = await ctx.db.query.proProfiles.findFirst({
|
||||
where: eq(schema.proProfiles.userId, input.proId),
|
||||
});
|
||||
if (!pro || pro.verificationStatus !== 'verified' || !pro.isAcceptingJobs) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'That pro is not available' });
|
||||
}
|
||||
|
||||
// A left swipe is just a tombstone — no cap, no contention, no transaction.
|
||||
if (input.direction === 'left') {
|
||||
await ctx.db
|
||||
.insert(schema.swipes)
|
||||
.values({ jobId: input.jobId, proId: input.proId, direction: 'left' })
|
||||
.onConflictDoNothing({ target: [schema.swipes.jobId, schema.swipes.proId] });
|
||||
return { requested: false as const };
|
||||
}
|
||||
|
||||
const ttlHours = REQUEST_TTL_HOURS[job.urgency];
|
||||
|
||||
/**
|
||||
* A right swipe has to be atomic.
|
||||
*
|
||||
* Counting pending requests and then inserting one is a read-then-write race:
|
||||
* two swipes landing together both read 4, both insert, and the client ends up
|
||||
* with 6 pros on a job capped at 5. Locking the job row serialises every swipe
|
||||
* on that job, which is the correct granularity — swipes on different jobs
|
||||
* never block each other.
|
||||
*
|
||||
* The cap is checked BEFORE the tombstone is written, so a rejected swipe
|
||||
* leaves no trace and the card legitimately stays on the deck for a retry.
|
||||
* (Writing the tombstone first and rolling back would make a dismissed card
|
||||
* reappear, which contradicts how the deck client is meant to behave.)
|
||||
*/
|
||||
return await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.select({ id: schema.jobs.id })
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, input.jobId))
|
||||
.for('update');
|
||||
|
||||
const [open] = await tx
|
||||
.select({ n: count() })
|
||||
.from(schema.requests)
|
||||
.where(
|
||||
and(eq(schema.requests.jobId, input.jobId), eq(schema.requests.status, 'pending')),
|
||||
);
|
||||
|
||||
if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS_PER_JOB) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: `You already have ${MAX_OPEN_REQUESTS_PER_JOB} pros considering this job. Wait for one to reply before sending more.`,
|
||||
});
|
||||
}
|
||||
|
||||
await tx
|
||||
.insert(schema.swipes)
|
||||
.values({ jobId: input.jobId, proId: input.proId, direction: 'right' })
|
||||
.onConflictDoNothing({ target: [schema.swipes.jobId, schema.swipes.proId] });
|
||||
|
||||
const [request] = await tx
|
||||
.insert(schema.requests)
|
||||
.values({
|
||||
jobId: input.jobId,
|
||||
proId: input.proId,
|
||||
expiresAt: new Date(Date.now() + ttlHours * 3_600_000),
|
||||
})
|
||||
.onConflictDoNothing({ target: [schema.requests.jobId, schema.requests.proId] })
|
||||
.returning();
|
||||
|
||||
// TODO(M3): enqueue a notification to the pro (web push + email) via BullMQ.
|
||||
|
||||
return {
|
||||
requested: true as const,
|
||||
requestId: request?.id ?? null,
|
||||
expiresInHours: ttlHours,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
/** Undo the last swipe on a job, as long as it has not become a live request. */
|
||||
undo: clientProcedure
|
||||
.input(z.object({ jobId: z.string().uuid(), proId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await requireOwnedJob(ctx, input.jobId);
|
||||
|
||||
const existing = await ctx.db.query.requests.findFirst({
|
||||
where: and(
|
||||
eq(schema.requests.jobId, input.jobId),
|
||||
eq(schema.requests.proId, input.proId),
|
||||
),
|
||||
});
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'That pro has already been sent this job, so it cannot be undone',
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.delete(schema.swipes)
|
||||
.where(
|
||||
and(eq(schema.swipes.jobId, input.jobId), eq(schema.swipes.proId, input.proId)),
|
||||
);
|
||||
return { undone: true };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, desc, eq, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import { assertTransition, createJobSchema } from '@linkder/shared';
|
||||
import { clientProcedure, publicProcedure, router } from '../trpc';
|
||||
|
||||
export const jobRouter = router({
|
||||
categories: publicProcedure.query(({ ctx }) =>
|
||||
ctx.db
|
||||
.select()
|
||||
.from(schema.categories)
|
||||
.where(eq(schema.categories.isActive, true))
|
||||
.orderBy(schema.categories.position),
|
||||
),
|
||||
|
||||
create: clientProcedure.input(createJobSchema).mutation(async ({ ctx, input }) => {
|
||||
const category = await ctx.db.query.categories.findFirst({
|
||||
where: eq(schema.categories.id, input.categoryId),
|
||||
});
|
||||
if (!category || !category.isActive) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown trade' });
|
||||
}
|
||||
|
||||
const [job] = await ctx.db
|
||||
.insert(schema.jobs)
|
||||
.values({
|
||||
clientId: ctx.session.userId,
|
||||
categoryId: input.categoryId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
photos: input.photos,
|
||||
urgency: input.urgency,
|
||||
budgetMinCents: input.budgetMinCents ?? null,
|
||||
budgetMaxCents: input.budgetMaxCents ?? null,
|
||||
location: input.location,
|
||||
addressText: input.addressText,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!job) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Could not post job' });
|
||||
return job;
|
||||
}),
|
||||
|
||||
/** The caller's own jobs, newest first. */
|
||||
mine: clientProcedure.query(({ ctx }) =>
|
||||
ctx.db
|
||||
.select()
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.clientId, ctx.session.userId))
|
||||
.orderBy(desc(schema.jobs.createdAt)),
|
||||
),
|
||||
|
||||
byId: clientProcedure.input(z.object({ id: z.string().uuid() })).query(async ({ ctx, input }) => {
|
||||
const job = await ctx.db.query.jobs.findFirst({
|
||||
where: eq(schema.jobs.id, input.id),
|
||||
with: { category: true },
|
||||
});
|
||||
// 404 for someone else's job — never confirm it exists.
|
||||
if (!job || (job.clientId !== ctx.session.userId && ctx.session.role !== 'admin')) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' });
|
||||
}
|
||||
|
||||
const [pending] = await ctx.db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(schema.requests)
|
||||
.where(and(eq(schema.requests.jobId, job.id), eq(schema.requests.status, 'pending')));
|
||||
|
||||
return { ...job, pendingRequests: pending?.n ?? 0 };
|
||||
}),
|
||||
|
||||
cancel: clientProcedure
|
||||
.input(z.object({ id: z.string().uuid(), reason: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const job = await ctx.db.query.jobs.findFirst({ where: eq(schema.jobs.id, input.id) });
|
||||
if (!job || (job.clientId !== ctx.session.userId && ctx.session.role !== 'admin')) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' });
|
||||
}
|
||||
|
||||
// Throws if the job is already completed or cancelled.
|
||||
assertTransition('job', job.status, 'cancelled');
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(schema.jobs)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(schema.jobs.id, job.id));
|
||||
|
||||
// Pros should stop seeing a job nobody can win.
|
||||
await tx
|
||||
.update(schema.requests)
|
||||
.set({ status: 'expired', respondedAt: new Date() })
|
||||
.where(
|
||||
and(eq(schema.requests.jobId, job.id), eq(schema.requests.status, 'pending')),
|
||||
);
|
||||
});
|
||||
|
||||
return { cancelled: true };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { createPresignedUpload, publicUrl, uploadRequestSchema, StorageError } from '@linkder/storage';
|
||||
import { protectedProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
* Hands out short-lived presigned PUTs so the browser uploads straight to R2.
|
||||
*
|
||||
* The server picks the key, so a caller can only ever write under their own
|
||||
* user id — they cannot overwrite someone else's document by guessing a path.
|
||||
*/
|
||||
export const uploadRouter = router({
|
||||
presign: protectedProcedure.input(uploadRequestSchema).mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const upload = await createPresignedUpload({ ...input, ownerId: ctx.session.userId });
|
||||
return {
|
||||
...upload,
|
||||
// Credentials are private; the caller stores the key and admins read it
|
||||
// through a signed GET rather than a public URL.
|
||||
publicUrl: input.kind === 'credential' ? null : publicUrl(upload.key),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof StorageError) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { TRPCError, initTRPC } from '@trpc/server';
|
||||
import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { schema } from '@linkder/db';
|
||||
import type { Context } from './context';
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
// Our schema is full of timestamps; without superjson every Date arrives as a
|
||||
// string and every consumer has to remember to re-hydrate it.
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
// Surface field-level validation errors so forms can attach them to inputs.
|
||||
zod: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const middleware = t.middleware;
|
||||
export const mergeRouters = t.mergeRouters;
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
|
||||
/**
|
||||
* Records the caller as active. Deck ranking uses `lastActiveAt` — a dormant pro
|
||||
* ranks lower — so it has to actually be maintained.
|
||||
*
|
||||
* Fire-and-forget: a failed heartbeat must never fail the request it rode in on.
|
||||
* Throttled to once an hour to avoid a write on every single call.
|
||||
*/
|
||||
const HEARTBEAT_INTERVAL_MS = 3_600_000;
|
||||
const lastHeartbeat = new Map<string, number>();
|
||||
|
||||
const withHeartbeat = middleware(async ({ ctx, next }) => {
|
||||
const userId = ctx.session?.userId;
|
||||
if (userId) {
|
||||
const now = Date.now();
|
||||
const previous = lastHeartbeat.get(userId) ?? 0;
|
||||
if (now - previous > HEARTBEAT_INTERVAL_MS) {
|
||||
lastHeartbeat.set(userId, now);
|
||||
void ctx.db
|
||||
.update(schema.users)
|
||||
.set({ lastActiveAt: new Date() })
|
||||
.where(eq(schema.users.id, userId))
|
||||
.catch(() => {
|
||||
// Best effort. Losing a heartbeat costs a little ranking accuracy, nothing more.
|
||||
lastHeartbeat.delete(userId);
|
||||
});
|
||||
}
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
/** Open to anyone, signed in or not. */
|
||||
export const publicProcedure = t.procedure.use(withHeartbeat);
|
||||
|
||||
/** Requires a signed-in, non-banned user. */
|
||||
export const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
|
||||
if (!ctx.session) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be signed in' });
|
||||
}
|
||||
return next({ ctx: { ...ctx, session: ctx.session } });
|
||||
});
|
||||
|
||||
/** Requires a client account. Pros cannot post jobs to themselves. */
|
||||
export const clientProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (ctx.session.role !== 'client' && ctx.session.role !== 'admin') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only clients can do this' });
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
/**
|
||||
* Requires a pro account. Does NOT require verification — onboarding and
|
||||
* document upload have to work before a pro is verified.
|
||||
*/
|
||||
export const proProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (ctx.session.role !== 'pro' && ctx.session.role !== 'admin') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only professionals can do this' });
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
/**
|
||||
* Requires a pro who has actually passed verification.
|
||||
*
|
||||
* This is the gate on anything that touches a real job: accepting a request,
|
||||
* quoting, being paid. An unverified or suspended pro must not get past here.
|
||||
*/
|
||||
export const verifiedProProcedure = proProcedure.use(({ ctx, next }) => {
|
||||
if (ctx.session.role === 'admin') return next({ ctx });
|
||||
if (ctx.session.verificationStatus !== 'verified') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Your account is still being verified. We will email you as soon as it is approved.',
|
||||
});
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (ctx.session.role !== 'admin') {
|
||||
throw new TRPCError({ code: 'NOT_FOUND' }); // Do not reveal that admin routes exist.
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
Reference in New Issue
Block a user