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
+248
View File
@@ -0,0 +1,248 @@
/**
* Integration tests for the pro profile surface.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
*
* previewCard exists precisely because publicProfile refuses non-verified pros,
* so most of what follows is about it working where publicProfile cannot, and
* about reordering never reaching another pro's photos.
*/
import { config } from 'dotenv';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
const createCaller = createCallerFactory(appRouter);
type Session = import('../src/context').Session;
function callerFor(session: Session | null) {
return createCaller(createInnerContext({ db, session }));
}
const proSession = (userId: string, verificationStatus: string): Session => ({
userId,
role: 'pro',
name: 'Test Pro',
email: 'pro@test',
phone: null,
verificationStatus: verificationStatus as Session['verificationStatus'],
});
let verifiedPro: string;
let pendingPro: string;
let otherPro: string;
beforeAll(async () => {
const verified = await db.execute<{ user_id: string }>(sql`
SELECT p.user_id FROM pro_profiles p
WHERE p.verification_status = 'verified' ORDER BY p.user_id LIMIT 2
`);
verifiedPro = verified[0]!.user_id;
otherPro = verified[1]!.user_id;
const pending = await db.execute<{ user_id: string }>(sql`
SELECT p.user_id FROM pro_profiles p
WHERE p.verification_status = 'pending' ORDER BY p.user_id LIMIT 1
`);
pendingPro = pending[0]!.user_id;
});
afterAll(async () => {
// The skills tests write to real seeded profiles; put them back empty.
await db.execute(
sql`UPDATE pro_profiles SET skills = '{}'::text[] WHERE user_id IN (${verifiedPro}, ${pendingPro})`,
);
await closePool();
});
describe('pro.previewCard', () => {
it('returns the callers own card', async () => {
const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard();
expect(card?.proId).toBe(verifiedPro);
expect(card?.headline).toBeTruthy();
});
it('works for a pro whose verification has NOT passed', async () => {
// publicProfile 404s here, which is why this procedure exists: the moment a
// pro most needs to see their card is before they are approved.
const card = await callerFor(proSession(pendingPro, 'pending')).pro.previewCard();
expect(card?.proId).toBe(pendingPro);
await expect(
callerFor(proSession(pendingPro, 'pending')).pro.publicProfile({ proId: pendingPro }),
).rejects.toThrow();
});
it('carries the photos in deck order, lead photo first', async () => {
const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard();
const rows = await db.execute<{ url: string }>(sql`
SELECT url FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
expect(card?.photos[0]).toBe(rows[0]!.url);
});
it('is not reachable by a client', async () => {
await expect(
callerFor({
userId: verifiedPro,
role: 'client',
name: null,
email: null,
phone: null,
verificationStatus: null,
}).pro.previewCard(),
).rejects.toThrow();
});
});
describe('pro.reorderMedia', () => {
it('puts the chosen photo first', async () => {
const before = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
const reversed = before.map((r) => r.id).reverse();
await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
orderedIds: reversed,
});
const after = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
expect(after.map((r) => r.id)).toEqual(reversed);
});
it('refuses a list containing another pros photo', async () => {
const mine = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
const theirs = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${otherPro} LIMIT 1
`);
const smuggled = [...mine.slice(1).map((r) => r.id), theirs[0]!.id];
await expect(
callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({ orderedIds: smuggled }),
).rejects.toThrow();
});
it('refuses a partial list, which would leave gaps in the ordering', async () => {
const mine = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
await expect(
callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
orderedIds: [mine[0]!.id],
}),
).rejects.toThrow();
});
it('leaves the other pros photos untouched', async () => {
const theirsBefore = await db.execute<{ id: string; position: number }>(sql`
SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position
`);
const mine = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
orderedIds: mine.map((r) => r.id).reverse(),
});
const theirsAfter = await db.execute<{ id: string; position: number }>(sql`
SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position
`);
expect(theirsAfter).toEqual(theirsBefore);
});
});
describe('pro.updateSkills', () => {
it('saves the list and hands it back on pro.me', async () => {
const caller = callerFor(proSession(verifiedPro, 'verified'));
const result = await caller.pro.updateSkills({
skills: ['Underfloor heating', 'Emergency callouts'],
});
expect(result.skills).toEqual(['Underfloor heating', 'Emergency callouts']);
const profile = await caller.pro.me();
expect(profile?.skills).toEqual(['Underfloor heating', 'Emergency callouts']);
});
it('replaces the whole list rather than appending', async () => {
const caller = callerFor(proSession(verifiedPro, 'verified'));
const result = await caller.pro.updateSkills({ skills: ['Bathroom fitting'] });
expect(result.skills).toEqual(['Bathroom fitting']);
});
it('trims and drops case-insensitive duplicates', async () => {
const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
skills: [' Leak detection ', 'leak detection', 'LEAK DETECTION', 'Boiler swaps'],
});
// First spelling wins; the rest are the same claim twice.
expect(result.skills).toEqual(['Leak detection', 'Boiler swaps']);
});
it('refuses more than the cap, and entries that are too long', async () => {
const caller = callerFor(proSession(verifiedPro, 'verified'));
await expect(
caller.pro.updateSkills({ skills: Array.from({ length: 13 }, (_, i) => `Skill ${i}`) }),
).rejects.toThrow();
await expect(caller.pro.updateSkills({ skills: ['x'.repeat(41)] })).rejects.toThrow();
await expect(caller.pro.updateSkills({ skills: ['a'] })).rejects.toThrow();
});
it('accepts an empty list, so a pro can clear it', async () => {
const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
skills: [],
});
expect(result.skills).toEqual([]);
});
it('does NOT send a verified pro back for review', async () => {
await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
skills: ['Listed buildings'],
});
const rows = await db.execute<{ status: string }>(
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${verifiedPro}`,
);
// Skills are description, not a licensed claim — demoting for one would
// just teach pros to leave the field empty.
expect(rows[0]!.status).toBe('verified');
});
it('works before verification has passed', async () => {
const result = await callerFor(proSession(pendingPro, 'pending')).pro.updateSkills({
skills: ['Rewiring'],
});
expect(result.skills).toEqual(['Rewiring']);
});
it('never touches another pro row', async () => {
await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({ skills: ['Mine'] });
const rows = await db.execute<{ skills: string[] }>(
sql`SELECT skills FROM pro_profiles WHERE user_id = ${otherPro}`,
);
expect(rows[0]!.skills).not.toContain('Mine');
});
it('rejects a caller who is not a pro', async () => {
const client: Session = {
userId: verifiedPro,
role: 'client',
name: 'Test Client',
email: 'client@test',
phone: null,
verificationStatus: null,
};
await expect(callerFor(client).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow();
await expect(callerFor(null).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow();
});
});