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:
serfowi
2026-08-21 03:16:27 -04:00
co-authored by Claude Opus 5
parent 582f13fa99
commit 176ba187c8
51 changed files with 15622 additions and 85 deletions
+2
View File
@@ -3,6 +3,7 @@ import { deckRouter } from './routers/deck';
import { jobRouter } from './routers/job';
import { proRouter } from './routers/pro';
import { uploadRouter } from './routers/upload';
import { notificationRouter } from './routers/notification';
import { userRouter } from './routers/user';
/**
@@ -15,6 +16,7 @@ export const appRouter = router({
pro: proRouter,
upload: uploadRouter,
user: userRouter,
notification: notificationRouter,
});
export type AppRouter = typeof appRouter;
+29 -2
View File
@@ -42,7 +42,15 @@ export const deckRouter = router({
* stops. Nobody is contacted until the visitor posts an actual job.
*/
showcase: publicProcedure
.input(z.object({ limit: z.number().int().min(1).max(DECK_PAGE_SIZE).optional() }).optional())
.input(
z
.object({
/** Narrow to one trade. Omitted shows every trade. */
categoryId: z.string().uuid().optional(),
limit: z.number().int().min(1).max(DECK_PAGE_SIZE).optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
const lat = Number(process.env.NEXT_PUBLIC_CITY_LAT);
const lng = Number(process.env.NEXT_PUBLIC_CITY_LNG);
@@ -54,7 +62,26 @@ export const deckRouter = router({
message: 'NEXT_PUBLIC_CITY_LAT / NEXT_PUBLIC_CITY_LNG are not set.',
});
}
const cards = await getShowcaseDeck(ctx.db, { lat, lng, limit: input?.limit });
// Someone who has told us where they are gets their own neighbourhood
// rather than the city centre, and only pros inside the range they set.
// An anonymous visitor still gets the city — this stays a public query.
const me = ctx.session
? await ctx.db.query.users.findFirst({
where: eq(schema.users.id, ctx.session.userId),
columns: { location: true, searchRadiusM: true },
})
: undefined;
const cards = await getShowcaseDeck(ctx.db, {
lat: me?.location?.lat ?? lat,
lng: me?.location?.lng ?? lng,
// Their limit applies only where we know their pin: measuring "5 km from
// me" from the city centre would be a different question entirely.
maxDistanceM: me?.location ? me.searchRadiusM : undefined,
categoryId: input?.categoryId,
limit: input?.limit,
});
return { cards };
}),
+67
View File
@@ -0,0 +1,67 @@
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { schema } from '@linkder/db';
import { protectedProcedure, router } from '../trpc';
/**
* The preference set, mirrored from the table's own defaults.
*
* A user with no row gets these rather than a 404, so the settings screen never
* has to distinguish "never saved" from "saved the defaults".
*/
const DEFAULTS = {
smsNewRequest: true,
smsBookingReminder: true,
smsMarketing: false,
emailReceipts: true,
emailMarketing: false,
pushMessages: true,
pushRequests: true,
} as const;
const preferencesSchema = z.object({
smsNewRequest: z.boolean(),
smsBookingReminder: z.boolean(),
smsMarketing: z.boolean(),
emailReceipts: z.boolean(),
emailMarketing: z.boolean(),
pushMessages: z.boolean(),
pushRequests: z.boolean(),
});
export const notificationRouter = router({
get: protectedProcedure.query(async ({ ctx }) => {
const row = await ctx.db.query.notificationPreferences.findFirst({
where: eq(schema.notificationPreferences.userId, ctx.session.userId),
});
if (!row) return { ...DEFAULTS };
const { userId: _userId, updatedAt: _updatedAt, ...prefs } = row;
return prefs;
}),
/**
* Upsert. Always scoped to the caller — the userId comes from the session and
* is never accepted from the payload.
*/
update: protectedProcedure
.input(preferencesSchema.partial())
.mutation(async ({ ctx, input }) => {
const values = { ...DEFAULTS, ...input, userId: ctx.session.userId, updatedAt: new Date() };
const [saved] = await ctx.db
.insert(schema.notificationPreferences)
.values(values)
// Only the keys the caller actually sent are overwritten, so a partial
// update cannot silently reset the preferences it did not mention.
.onConflictDoUpdate({
target: schema.notificationPreferences.userId,
set: { ...input, updatedAt: new Date() },
})
.returning();
if (!saved) return { ...DEFAULTS };
const { userId: _userId, updatedAt: _updatedAt, ...prefs } = saved;
return prefs;
}),
});
+142 -1
View File
@@ -2,7 +2,12 @@ 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 {
assertTransition,
credentialSchema,
proProfileSchema,
updateSkillsSchema,
} from '@linkder/shared';
import { protectedProcedure, proProcedure, router } from '../trpc';
/**
@@ -145,6 +150,142 @@ export const proRouter = router({
return { saved: true, requiresReReview: sendBackForReview };
}),
/**
* Replace the skill list.
*
* Whole list rather than add/remove: the editor holds the full set anyway, and
* a delta API would need its own ordering and conflict rules for a field this
* small.
*
* Unlike trades, location and radius, this does NOT send a verified pro back
* for review. Skills are the pro's own description of their work, in the same
* class as the headline and bio — what verification actually checks is the
* licence behind a trade, and that is `pro_categories`. Demoting someone for
* typing "emergency callouts" would teach them to leave the field empty.
*/
updateSkills: proProcedure.input(updateSkillsSchema).mutation(async ({ ctx, input }) => {
const updated = await ctx.db
.update(schema.proProfiles)
.set({ skills: input.skills, updatedAt: new Date() })
.where(eq(schema.proProfiles.userId, ctx.session.userId))
.returning({ skills: schema.proProfiles.skills });
// proProcedure proves the caller is a pro, not that onboarding produced a row.
if (updated.length === 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Set up your pro profile before adding skills.',
});
}
return { skills: updated[0]!.skills };
}),
/**
* The caller's own deck card, at any verification status.
*
* `publicProfile` deliberately only returns `verified` pros, so a pro still in
* onboarding cannot use it to preview themselves — which is precisely when
* seeing the card matters most. This is that preview: same shape as DeckCard
* so the profile screen can render the real card component and the two cannot
* drift apart.
*/
previewCard: 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 [user] = await ctx.db
.select({ name: schema.users.name, image: schema.users.image })
.from(schema.users)
.where(eq(schema.users.id, ctx.session.userId));
const [media, categories] = await Promise.all([
ctx.db
.select({ url: schema.proMedia.url })
.from(schema.proMedia)
.where(eq(schema.proMedia.proId, ctx.session.userId))
.orderBy(schema.proMedia.position),
ctx.db
.select({ name: schema.categories.name })
.from(schema.proCategories)
.innerJoin(schema.categories, eq(schema.categories.id, schema.proCategories.categoryId))
.where(eq(schema.proCategories.proId, ctx.session.userId)),
]);
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,
responseRate: profile.responseRate === null ? null : Number(profile.responseRate),
avgResponseMinutes: profile.avgResponseMinutes,
// A distance only exists relative to a client's job. There is no client
// here, so the preview shows a representative figure and the screen must
// label it as such rather than implying it is real.
distanceM: 2_400,
photos: media.map((m) => m.url),
categories: categories.map((c) => c.name),
score: 0,
verificationStatus: profile.verificationStatus,
isAcceptingJobs: profile.isAcceptingJobs,
};
}),
/**
* Reorder photos.
*
* Position 0 is the deck card — the single highest-conversion field a pro
* controls — so this is not cosmetic.
*
* Takes the full ordered id list rather than a move-one-item delta: a partial
* update would leave gaps or duplicate positions if a request were lost.
*/
reorderMedia: proProcedure
.input(z.object({ orderedIds: z.array(z.string().uuid()).min(1).max(10) }))
.mutation(async ({ ctx, input }) => {
const owned = await ctx.db
.select({ id: schema.proMedia.id })
.from(schema.proMedia)
.where(eq(schema.proMedia.proId, ctx.session.userId));
const ownedIds = new Set(owned.map((m) => m.id));
// Every id must belong to the caller, and the list must be the WHOLE set —
// otherwise a caller could smuggle in someone else's photo id, or silently
// drop their own photos out of the ordering.
if (input.orderedIds.length !== ownedIds.size) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Send every photo id, in the new order.',
});
}
for (const id of input.orderedIds) {
if (!ownedIds.has(id)) throw new TRPCError({ code: 'NOT_FOUND' });
}
await ctx.db.transaction(async (tx) => {
for (const [index, id] of input.orderedIds.entries()) {
await tx
.update(schema.proMedia)
.set({ position: index })
.where(
and(eq(schema.proMedia.id, id), eq(schema.proMedia.proId, ctx.session.userId)),
);
}
});
return { ordered: true };
}),
/** Attach an uploaded photo. The file itself went straight to R2. */
addMedia: proProcedure
.input(
+299 -10
View File
@@ -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 };
}),
});