Skip to content

Commit 61403ad

Browse files
byw1claude
andcommitted
fix: images were gated behind S3, silently; add a page per person
Every picture in the product required an object-storage bucket, and nothing anywhere said so. `storageEnabled` needs all four of S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY; miss one and it is off with a neutral dot on the Health tab reading "not configured", which looks like a feature you declined rather than the reason nobody has a face. Contact photos no longer touch S3. The sync used to skip fetching them entirely when storage was off, so avatarUrl was never written and every contact fell back to initials permanently. They are a few KB — they live in the contacts table now, capped at 512KB, and the route serves the bytes with an ETag off the row's mtime. The S3 branch stays only for installs that already synced one. Inbound photos were worse: the processor marked them `failed`, which is terminal, so configuring a bucket later brought none of them back. A missing bucket is a deployment state, not a property of the attachment, so they stay pending and a sweep re-queues them once there is somewhere to put the bytes — including a one-time rescue of the rows already written off. The Health tab now says what the missing bucket costs and how many files are waiting on it. Then the page. Clicking someone's face — in the details panel, or on a member of a group — opens /people/<id>: their profile, editable in place, and every thread they are in. Not in the nav, deliberately. A Contacts entry would frame it as a directory you go and browse, and this is the answer to "who am I actually talking to", asked from inside a conversation. It is scoped to the contact rather than a thread, which is the point: the thread list unions conversations linked to the contact with any group they appear in as a participant, since a group carries no contactId and keying on that column alone would show someone's one-to-one and silently omit the four group chats they are in. Three columns that existed but had no UI now have one. Identities can be added and removed, so the same human texting from two numbers stops being two strangers — refusing an address another contact already owns, since moving it would quietly empty their history, and refusing to remove the last one, since a contact with no address matches nothing and would fork into a duplicate on their next message. optedOutAt gets a switch: STOP has been enforced at every send path since the first release, but nothing could read the flag, so the only way to discover it was to be refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CY9GSvGD8JYnMyU5Q6BW5r
1 parent ad104ef commit 61403ad

19 files changed

Lines changed: 4089 additions & 54 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { notFound } from 'next/navigation';
2+
import { getPersonProfile } from '@/server/queries';
3+
import { requireDbUser } from '@/lib/session';
4+
import { PersonProfile } from '@/components/people/person-profile';
5+
6+
export const dynamic = 'force-dynamic';
7+
8+
/**
9+
* A person's own page.
10+
*
11+
* Deliberately absent from the nav — you get here by clicking someone's face,
12+
* from the details panel or a group's member list. A "Contacts" nav entry
13+
* would frame this as a directory to browse; it isn't. It's the answer to
14+
* "who am I actually talking to", asked from inside a conversation.
15+
*/
16+
export default async function PersonPage({
17+
params,
18+
}: {
19+
params: Promise<{ contactId: string }>;
20+
}) {
21+
const { contactId } = await params;
22+
await requireDbUser();
23+
24+
const profile = await getPersonProfile(contactId);
25+
if (!profile) notFound();
26+
27+
return (
28+
<div className="min-h-0 flex-1 overflow-y-auto">
29+
<PersonProfile
30+
data={{
31+
contact: {
32+
id: profile.contact.id,
33+
displayName: profile.contact.displayName,
34+
company: profile.contact.company,
35+
notes: profile.contact.notes,
36+
avatarUrl: profile.contact.avatarUrl,
37+
attributes: profile.contact.attributes,
38+
optedOut: Boolean(profile.contact.optedOutAt),
39+
syncedAt: profile.contact.syncedAt?.toISOString() ?? null,
40+
},
41+
identities: profile.identities,
42+
conversations: profile.conversations.map((c) => ({
43+
...c,
44+
lastMessageAt: c.lastMessageAt?.toISOString() ?? null,
45+
})),
46+
stats: {
47+
...profile.stats,
48+
firstMessageAt: profile.stats.firstMessageAt?.toISOString() ?? null,
49+
lastInboundAt: profile.stats.lastInboundAt?.toISOString() ?? null,
50+
},
51+
photos: profile.photos,
52+
photoCount: profile.photoCount,
53+
}}
54+
/>
55+
</div>
56+
);
57+
}

apps/web/src/app/api/avatars/[contactId]/route.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import { getCurrentUser } from '@/lib/session';
77
export const runtime = 'nodejs';
88
export const dynamic = 'force-dynamic';
99

10-
/** Redirect to a short-lived presigned URL for a synced address-book photo. */
10+
/**
11+
* A contact's address-book photo.
12+
*
13+
* Served from the database, where the sync now puts it. The S3 branch is only
14+
* for installs that synced before that changed and still have a key on the
15+
* row — nothing writes one any more.
16+
*/
1117
export async function GET(
1218
req: Request,
1319
{ params }: { params: Promise<{ contactId: string }> },
@@ -16,11 +22,35 @@ export async function GET(
1622
if (!user) return new Response('Unauthorized', { status: 401 });
1723

1824
const { contactId } = await params;
19-
const contact = await db.query.contacts.findFirst({ where: eq(contacts.id, contactId) });
20-
if (!contact?.avatarStorageKey || !isStorageEnabled()) {
21-
return new Response('Not found', { status: 404 });
25+
const contact = await db.query.contacts.findFirst({
26+
where: eq(contacts.id, contactId),
27+
columns: { avatarData: true, avatarMime: true, avatarStorageKey: true, updatedAt: true },
28+
});
29+
if (!contact) return new Response('Not found', { status: 404 });
30+
31+
if (contact.avatarData) {
32+
const bytes = Buffer.from(contact.avatarData, 'base64');
33+
// Private, because the photo is only for signed-in members of this
34+
// workspace; revalidated against the row's mtime so a re-sync shows the
35+
// new face without waiting the hour out.
36+
const etag = `W/"${contactId}-${contact.updatedAt?.getTime() ?? 0}"`;
37+
if (req.headers.get('if-none-match') === etag) {
38+
return new Response(null, { status: 304, headers: { ETag: etag } });
39+
}
40+
return new Response(bytes, {
41+
headers: {
42+
'Content-Type': contact.avatarMime ?? 'image/jpeg',
43+
'Content-Length': String(bytes.length),
44+
'Cache-Control': 'private, max-age=3600, must-revalidate',
45+
ETag: etag,
46+
},
47+
});
48+
}
49+
50+
if (contact.avatarStorageKey && isStorageEnabled()) {
51+
const url = await getPresignedUrl(contact.avatarStorageKey, 3600);
52+
return Response.redirect(url, 302);
2253
}
2354

24-
const url = await getPresignedUrl(contact.avatarStorageKey, 3600);
25-
return Response.redirect(url, 302);
55+
return new Response('Not found', { status: 404 });
2656
}

apps/web/src/components/inbox/person-card.tsx

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,34 @@ export interface PersonCardProps {
2424
contactId?: string | null;
2525
}
2626

27+
/**
28+
* Wraps a bit of the card in a link to the person's page — or doesn't, when
29+
* there is no contact row behind the thread. Rendering a dead link to
30+
* `/people/null` would look identical right up until someone clicked it.
31+
*/
32+
function PersonLink({
33+
contactId,
34+
label,
35+
className,
36+
children,
37+
}: {
38+
contactId: string | null | undefined;
39+
label: string;
40+
className?: string;
41+
children: React.ReactNode;
42+
}) {
43+
if (!contactId) return <>{children}</>;
44+
return (
45+
<Link
46+
href={`/people/${contactId}`}
47+
title={`Open ${label}'s profile`}
48+
className={cn('block shrink-0', className)}
49+
>
50+
{children}
51+
</Link>
52+
);
53+
}
54+
2755
/**
2856
* Who you are talking to.
2957
*
@@ -42,14 +70,26 @@ export function PersonCard(p: PersonCardProps) {
4270
return (
4371
<div className="px-4 pb-4 pt-4">
4472
<div className="flex items-center gap-3">
45-
<Avatar className="h-12 w-12 ring-1 ring-border">
46-
{p.avatarUrl && <AvatarImage src={p.avatarUrl} alt={p.name} />}
47-
<AvatarFallback className="type-item bg-secondary font-semibold text-muted-foreground">
48-
{initials(p.name)}
49-
</AvatarFallback>
50-
</Avatar>
73+
{/* Their face is the way into their page. A group has no single
74+
person behind it, so there it stays a picture. */}
75+
<PersonLink contactId={p.isGroup ? null : p.contactId} label={p.name}>
76+
<Avatar className="h-12 w-12 ring-1 ring-border">
77+
{p.avatarUrl && <AvatarImage src={p.avatarUrl} alt={p.name} />}
78+
<AvatarFallback className="type-item bg-secondary font-semibold text-muted-foreground">
79+
{initials(p.name)}
80+
</AvatarFallback>
81+
</Avatar>
82+
</PersonLink>
5183
<div className="min-w-0">
52-
<p className="type-title truncate">{p.name}</p>
84+
<p className="type-title truncate">
85+
<PersonLink
86+
contactId={p.isGroup ? null : p.contactId}
87+
label={p.name}
88+
className="rounded transition-colors hover:text-brand"
89+
>
90+
{p.name}
91+
</PersonLink>
92+
</p>
5393
{local && (
5494
<p
5595
className={cn(

apps/web/src/components/inbox/ticket-panel.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { useEffect, useState, useTransition } from 'react';
4+
import Link from 'next/link';
45
import { useRouter } from 'next/navigation';
56
import { toast } from 'sonner';
67
import {
@@ -232,8 +233,8 @@ export function TicketPanel({
232233
{participants.map((m) => {
233234
const display = m.name || formatAddress(m.rawAddress ?? m.address) || m.address;
234235
const sub = m.name ? (formatAddress(m.rawAddress ?? m.address) ?? m.address) : null;
235-
return (
236-
<div key={m.address} className="flex items-center gap-2.5">
236+
const body = (
237+
<>
237238
<Avatar className="h-7 w-7 ring-1 ring-border">
238239
{m.avatarUrl && <AvatarImage src={m.avatarUrl} alt="" />}
239240
<AvatarFallback className="bg-secondary text-[10px] font-semibold text-muted-foreground">
@@ -251,6 +252,23 @@ export function TicketPanel({
251252
{!m.name && (
252253
<span className="type-caption shrink-0 text-muted-foreground/60">unknown</span>
253254
)}
255+
</>
256+
);
257+
258+
// A member with no contact row is just an address we've seen in
259+
// this chat — there is no page to open, so it stays inert
260+
// rather than offering a link that 404s.
261+
return m.contactId ? (
262+
<Link
263+
key={m.address}
264+
href={`/people/${m.contactId}`}
265+
className="-mx-1.5 flex items-center gap-2.5 rounded-lg px-1.5 py-1 transition-colors hover:bg-accent"
266+
>
267+
{body}
268+
</Link>
269+
) : (
270+
<div key={m.address} className="flex items-center gap-2.5">
271+
{body}
254272
</div>
255273
);
256274
})}

0 commit comments

Comments
 (0)