M1: phone app shell, settings, profile, dev login
Everything now renders inside a phone illustration on the entry screen, with a five-tab bar. The frame lives in the root layout rather than one page, so sign-in, onboarding and the job form are inside it too. - Entry screen is the product running, not a marketing page: a live swipeable deck of real verified pros with a trade-filter strip above the card. deck.showcase is the only public procedure in that router and writes nothing, so an anonymous right swipe reaches no one. - Settings: notification preferences (new table, defaults returned when no row exists), signed-in devices, GDPR export, deletion request. Closes the setEmail finding: an unverified address is no longer written to users.email, which is UNIQUE -- claiming a stranger's address used to block them from ever signing up with Google, and the uniqueness error leaked whether an address was registered. Now parked in email_change_requests until a token proves ownership. - Profile: for a pro it leads with their REAL deck card, rendered by the same exported <Card> clients swipe, so the two cannot drift. Adds pro.previewCard (works at draft/pending, where publicProfile 404s) and pro.reorderMedia (photo position 0 is the deck card). Warns before an edit that would send a verified pro back for review, rather than after it silently drops them off the deck. Clients get a thin profile plus a route into pro onboarding -- supply is the launch blocker. - Dev login: +34600000000 / 000000, behind THREE guards (NODE_ENV, an explicit ALLOW_DEV_LOGIN flag, and an exact number match). It overwrites the stored code rather than skipping verification, so the real expiry, attempt cap and single-use consumption still apply. - Seed uses portrait photos. The cards previously showed picsum stock scenery -- a locksmith standing on a railway track. Fixes found along the way: the card's name rendered ink-950 navy on a dark photo because globals.css sets h1..h6 colour in @layer base, which beat the inherited text-white; and the card referenced --color-go-500, --border and --card, none of which exist, so the SEND JOB stamp had no colour. Also adds public/sw.js as a kill-switch: a service worker left registered on localhost:3000 by a different project was intercepting this app's chunks. typecheck, lint clean; 186 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import { isContactableEmail } from '@linkder/shared';
|
||||
import { protectedProcedure, router } from '../trpc';
|
||||
import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkder/shared';
|
||||
import { protectedProcedure, publicProcedure, router } from '../trpc';
|
||||
|
||||
export const userRouter = router({
|
||||
/** Who am I — the shape the client needs to decide what to render. */
|
||||
@@ -105,12 +106,21 @@ export const userRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set a real email address.
|
||||
* 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.
|
||||
*/
|
||||
setEmail: protectedProcedure
|
||||
requestEmailChange: protectedProcedure
|
||||
.input(z.object({ email: z.string().email() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
@@ -121,23 +131,302 @@ export const userRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
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, email),
|
||||
where: eq(schema.users.email, request.email),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (taken && taken.id !== ctx.session.userId) {
|
||||
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({ email, emailVerified: false, updatedAt: new Date() })
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.image !== undefined ? { image: input.image } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.users.id, ctx.session.userId));
|
||||
|
||||
// TODO(M1): send a confirmation link before treating it as verified.
|
||||
return { email };
|
||||
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 a display string with no matching role, so it lives on the
|
||||
// user for everyone rather than being duplicated per role.
|
||||
if (input.addressText !== undefined) {
|
||||
await ctx.db
|
||||
.update(schema.users)
|
||||
.set({ locationText: input.addressText || null, updatedAt: new Date() })
|
||||
.where(eq(schema.users.id, ctx.session.userId));
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
if (input.location !== undefined || input.radiusM !== undefined) {
|
||||
await ctx.db
|
||||
.update(schema.users)
|
||||
.set({
|
||||
...(input.location !== undefined ? { location: input.location } : {}),
|
||||
...(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 =
|
||||
input.location !== undefined &&
|
||||
(input.location.lat !== profile.baseLocation.lat ||
|
||||
input.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({
|
||||
...(input.location !== undefined ? { baseLocation: input.location } : {}),
|
||||
...(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 };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user