Client avatars, and Robert Pérez on the demo account

`users.image` was null on every seeded customer, and it is the face on
every review a pro has — `pro.reviews` selects it as `authorImage` — as
well as the chat header. So the one screen meant to prove other people
have used this rendered as a column of blank circles.

Five customers now carry a portrait, seeded with the source url and
rewritten to the bucket like pro_media, and the first of them — the
dev-login account the demo signs in as — is Robert Pérez.

assets:migrate only knew about pro_media and job photos, so an avatar
would have stayed on someone else's CDN indefinitely. It has a users
pass now, which also catches the provider avatar a social sign-in writes
straight onto the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfa
2026-08-23 11:02:57 -04:00
co-authored by Claude Opus 5
parent 1808ad4cba
commit ff1882598c
9 changed files with 299 additions and 21 deletions
+10
View File
@@ -17,6 +17,16 @@ export interface Session {
phone: string | null;
/** Only present for pros. Gates the procedures that require a live, verified pro. */
verificationStatus: VerificationStatus | null;
/**
* Which session row this request arrived on, so the security screen can say
* "this device" and mean it.
*
* Optional because not every caller has one: the server-side caller and the
* test fakes construct a session directly, with no row behind it. Anything
* reading this must treat "absent" as "cannot tell", never as "not current" —
* marking the wrong device as the current one is worse than marking none.
*/
sessionId?: string;
}
/**
+8 -1
View File
@@ -364,7 +364,14 @@ export const userRouter = router({
.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 }));
return rows.map((r) => ({
...r,
isImpersonated: r.impersonatedBy !== null,
// False when the caller has no session id rather than guessing. A device
// list that mislabels which one you are holding is worse than one that
// labels none of them.
isCurrent: ctx.session.sessionId !== undefined && r.id === ctx.session.sessionId,
}));
}),
/**
+23 -1
View File
@@ -163,7 +163,29 @@ async function main() {
jobPhotos += outward.length;
}
console.log(`\n${moved} pro images and ${jobPhotos} job photos now served from ${origin}`);
// Account avatars. Seeded clients carry one, and a social sign-in writes the
// provider's CDN url straight onto the row — both point outward.
const withAvatars = await db
.select({ id: schema.users.id, image: schema.users.image })
.from(schema.users)
.where(sql`${schema.users.image} IS NOT NULL`);
let avatars = 0;
for (const user of withAvatars) {
if (!user.image || isLocal(user.image)) continue;
const hosted = await adopt(user.image, 'avatars');
if (!hosted) continue;
await db
.update(schema.users)
.set({ image: hosted })
.where(sql`${schema.users.id} = ${user.id}`);
avatars += 1;
}
console.log(
`\n${moved} pro images, ${jobPhotos} job photos and ${avatars} avatars ` +
`now served from ${origin}`,
);
const left = (
await db.select({ url: schema.proMedia.url }).from(schema.proMedia)
+21 -3
View File
@@ -213,7 +213,22 @@ const PROS: SeedPro[] = [
{ name: 'Away Arturo', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true },
];
const CLIENTS = ['Sofía Guzmán', 'Daniel Miranda', 'Emma Rivera', 'Lucas Ponce', 'Alba Tovar'];
/**
* Customers.
*
* The avatar is not decoration: `users.image` is the face on every review a pro
* has (`pro.reviews` selects it as `authorImage`) and on the chat header. Left
* null, every review on every profile in the demo renders faceless, which is
* the one screen meant to look like other people have used this.
*/
const CLIENTS: { name: string; photo: string }[] = [
// The first one is the dev-login account — see the phone note below.
{ name: 'Robert Pérez', photo: 'photo-1500648767791-00dcc994a43e' },
{ name: 'Daniel Miranda', photo: 'photo-1507003211169-0a1dd7228f2d' },
{ name: 'Emma Rivera', photo: 'photo-1494790108377-be9c29b29330' },
{ name: 'Lucas Ponce', photo: 'photo-1506794778202-cad84cf45f1d' },
{ name: 'Alba Tovar', photo: 'photo-1438761681033-6461ffad8d80' },
];
/**
* Finished work, per trade, for the review histories below.
@@ -370,8 +385,11 @@ async function main() {
const clientRows = await db
.insert(schema.users)
.values(
CLIENTS.map((name, i) => ({
name,
CLIENTS.map((c, i) => ({
name: c.name,
// Seeded with the source url and rewritten to our bucket by
// `pnpm assets:migrate`, exactly as pro_media is.
image: `https://images.unsplash.com/${c.photo}?w=200&h=200&fit=crop`,
email: `client${i + 1}@linkder.test`,
/**
* The first client gets the dev-login number (see web/src/server/dev-login.ts).