Skip to content

Commit b4a3c0f

Browse files
bchapuisclaude
andcommitted
Replace support thread status with a single archived_at toggle
Inbound mail clears archived_at so a reply restores the thread to the inbox automatically, removing the need for an open/pending/closed workflow we weren't using. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 43bc6e1 commit b4a3c0f

7 files changed

Lines changed: 103 additions & 88 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Replace the open/pending/closed thread status with a single archived_at
2+
-- timestamp. NULL = active inbox thread, non-null = archived. The inbound
3+
-- email path clears archived_at when a new message arrives so archived
4+
-- threads automatically return to the inbox on reply.
5+
6+
ALTER TABLE `threads` ADD COLUMN `archived_at` integer;
7+
8+
-- Map existing closed threads to archived; pending threads fall back to
9+
-- active since the workflow no longer distinguishes them.
10+
UPDATE `threads` SET `archived_at` = `updated_at` WHERE `status` = 'closed';
11+
12+
DROP INDEX IF EXISTS `threads_status_idx`;
13+
14+
ALTER TABLE `threads` DROP COLUMN `status`;
15+
16+
CREATE INDEX IF NOT EXISTS `threads_archived_at_idx` ON `threads` (`archived_at`);

apps/api/src/db/schema/index.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,6 @@ export const SubscriptionStatus = {
122122
export type SubscriptionStatusType =
123123
(typeof SubscriptionStatus)[keyof typeof SubscriptionStatus];
124124

125-
// Inbox thread status
126-
export const ThreadStatus = {
127-
OPEN: "open",
128-
PENDING: "pending",
129-
CLOSED: "closed",
130-
} as const;
131-
132-
export type ThreadStatusType = (typeof ThreadStatus)[keyof typeof ThreadStatus];
133-
134125
// Inbox message direction
135126
export const MessageDirection = {
136127
INBOUND: "inbound",
@@ -585,17 +576,14 @@ export const threads = sqliteTable(
585576
organizationId: text("organization_id").references(() => organizations.id, {
586577
onDelete: "set null",
587578
}),
588-
status: text("status")
589-
.$type<ThreadStatusType>()
590-
.notNull()
591-
.default(ThreadStatus.OPEN),
579+
archivedAt: integer("archived_at", { mode: "timestamp" }),
592580
lastMessageAt: integer("last_message_at", { mode: "timestamp" }).notNull(),
593581
createdAt: createCreatedAt(),
594582
updatedAt: createUpdatedAt(),
595583
},
596584
(table) => [
597585
index("threads_inbox_id_idx").on(table.inboxId),
598-
index("threads_status_idx").on(table.status),
586+
index("threads_archived_at_idx").on(table.archivedAt),
599587
index("threads_last_message_at_idx").on(table.lastMessageAt),
600588
index("threads_from_email_idx").on(table.fromEmail),
601589
index("threads_user_id_idx").on(table.userId),

apps/api/src/db/support-queries.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
messages,
1313
type ThreadInsert,
1414
type ThreadRow,
15-
ThreadStatus,
1615
threads,
1716
users,
1817
} from "./index";
@@ -190,9 +189,8 @@ export async function insertAttachments(
190189
}
191190

192191
/**
193-
* Reopen and bump `lastMessageAt` on a thread that just received a new
194-
* inbound message. Closed threads are reopened (user replied to a resolved
195-
* conversation); pending threads also flip back to open.
192+
* Bump `lastMessageAt` on a thread that just received a new inbound message
193+
* and clear `archivedAt` so archived threads return to the inbox on reply.
196194
*/
197195
export async function touchThreadOnInbound(
198196
db: Database,
@@ -203,7 +201,7 @@ export async function touchThreadOnInbound(
203201
.update(threads)
204202
.set({
205203
lastMessageAt,
206-
status: ThreadStatus.OPEN,
204+
archivedAt: null,
207205
updatedAt: new Date(),
208206
})
209207
.where(eq(threads.id, threadId));

apps/api/src/routes/admin/support.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import { zValidator } from "@hono/zod-validator";
2-
import { and, asc, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm";
2+
import {
3+
and,
4+
asc,
5+
desc,
6+
eq,
7+
inArray,
8+
isNotNull,
9+
isNull,
10+
lt,
11+
or,
12+
sql,
13+
} from "drizzle-orm";
314
import { Hono } from "hono";
415
import { z } from "zod";
516

@@ -13,8 +24,6 @@ import {
1324
messages,
1425
organizations,
1526
selectLastInboundMessage,
16-
ThreadStatus,
17-
type ThreadStatusType,
1827
threadReads,
1928
threads,
2029
users,
@@ -24,18 +33,12 @@ import { inboxKeys, SUPPORT_INBOX_ALIAS } from "../../support-storage";
2433

2534
const adminSupportRoutes = new Hono<ApiContext>();
2635

27-
const statusEnum = z.enum([
28-
ThreadStatus.OPEN,
29-
ThreadStatus.PENDING,
30-
ThreadStatus.CLOSED,
31-
]);
32-
3336
const threadSummaryColumns = {
3437
id: threads.id,
3538
subject: threads.subject,
3639
fromEmail: threads.fromEmail,
3740
fromName: threads.fromName,
38-
status: threads.status,
41+
archivedAt: threads.archivedAt,
3942
lastMessageAt: threads.lastMessageAt,
4043
createdAt: threads.createdAt,
4144
updatedAt: threads.updatedAt,
@@ -54,17 +57,21 @@ adminSupportRoutes.get(
5457
z.object({
5558
page: z.coerce.number().min(1).default(1),
5659
limit: z.coerce.number().min(1).max(100).default(20),
57-
status: statusEnum.optional(),
60+
// "inbox" (default) hides archived; "archived" shows only archived;
61+
// "all" shows both. Kept as a string union so the URL stays readable.
62+
view: z.enum(["inbox", "archived", "all"]).default("inbox"),
5863
search: z.string().optional(),
5964
})
6065
),
6166
async (c) => {
6267
const db = createDatabase(c.env.DB);
63-
const { page, limit, status, search } = c.req.valid("query");
68+
const { page, limit, view, search } = c.req.valid("query");
6469
const offset = (page - 1) * limit;
6570

6671
const conditions = [];
67-
if (status) conditions.push(eq(threads.status, status));
72+
if (view === "inbox") conditions.push(isNull(threads.archivedAt));
73+
else if (view === "archived")
74+
conditions.push(isNotNull(threads.archivedAt));
6875
if (search) {
6976
const like_ = `%${search}%`;
7077
conditions.push(
@@ -135,9 +142,12 @@ adminSupportRoutes.get("/unread-count", async (c) => {
135142
)
136143
)
137144
.where(
138-
or(
139-
isNull(threadReads.lastReadAt),
140-
lt(threadReads.lastReadAt, threads.lastMessageAt)
145+
and(
146+
isNull(threads.archivedAt),
147+
or(
148+
isNull(threadReads.lastReadAt),
149+
lt(threadReads.lastReadAt, threads.lastMessageAt)
150+
)
141151
)
142152
);
143153

@@ -351,7 +361,6 @@ adminSupportRoutes.post(
351361
fromName: null,
352362
userId: linkedUser?.id ?? null,
353363
organizationId: linkedUser?.organizationId ?? null,
354-
status: ThreadStatus.OPEN,
355364
lastMessageAt: now,
356365
});
357366

@@ -440,18 +449,21 @@ adminSupportRoutes.post(
440449
}
441450
);
442451

443-
/** PATCH /admin/support/threads/:id — close / reopen. */
452+
/** PATCH /admin/support/threads/:id — archive / unarchive. */
444453
adminSupportRoutes.patch(
445454
"/threads/:id",
446-
zValidator("json", z.object({ status: statusEnum })),
455+
zValidator("json", z.object({ archived: z.boolean() })),
447456
async (c) => {
448457
const db = createDatabase(c.env.DB);
449458
const id = c.req.param("id");
450-
const { status } = c.req.valid("json");
459+
const { archived } = c.req.valid("json");
451460

452461
const result = await db
453462
.update(threads)
454-
.set({ status: status as ThreadStatusType, updatedAt: new Date() })
463+
.set({
464+
archivedAt: archived ? new Date() : null,
465+
updatedAt: new Date(),
466+
})
455467
.where(eq(threads.id, id))
456468
.returning();
457469

apps/api/src/support-email.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
insertMessage,
1414
MessageDirection,
1515
resolveThreadForInbound,
16-
ThreadStatus,
1716
threads,
1817
touchThreadOnInbound,
1918
} from "./db";
@@ -254,7 +253,6 @@ export async function handleSupportEmail(
254253
fromName: fromName ?? null,
255254
userId: linkedUser?.id ?? null,
256255
organizationId: linkedUser?.organizationId ?? null,
257-
status: ThreadStatus.OPEN,
258256
lastMessageAt: now,
259257
});
260258
threadId = thread.id;

apps/app/src/pages/admin/admin-support-page.tsx

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Archive from "lucide-react/icons/archive";
2+
import ArchiveRestore from "lucide-react/icons/archive-restore";
13
import Inbox from "lucide-react/icons/inbox";
24
import Paperclip from "lucide-react/icons/paperclip";
35
import PenSquare from "lucide-react/icons/pen-square";
@@ -36,14 +38,14 @@ import { Textarea } from "@/components/ui/textarea";
3638
import {
3739
AdminMessageDirection,
3840
type AdminThreadMessage,
39-
type AdminThreadStatus,
4041
type AdminThreadSummary,
42+
type AdminThreadView,
4143
type AdminUser,
4244
adminSupportAttachmentUrl,
4345
createAdminSupportThread,
4446
fetchAdminSupportMessageBody,
4547
sendAdminSupportReply,
46-
updateAdminSupportThreadStatus,
48+
updateAdminSupportThreadArchived,
4749
useAdminSupportThread,
4850
useAdminSupportThreads,
4951
useAdminSupportUnreadCount,
@@ -52,11 +54,10 @@ import {
5254
import { formatDate } from "@/utils/date";
5355
import { cn } from "@/utils/utils";
5456

55-
const STATUS_FILTERS: { value: AdminThreadStatus | "all"; label: string }[] = [
57+
const VIEW_FILTERS: { value: AdminThreadView; label: string }[] = [
58+
{ value: "inbox", label: "Inbox" },
59+
{ value: "archived", label: "Archived" },
5660
{ value: "all", label: "All" },
57-
{ value: "open", label: "Open" },
58-
{ value: "pending", label: "Pending" },
59-
{ value: "closed", label: "Closed" },
6061
];
6162

6263
export function AdminSupportPage() {
@@ -67,20 +68,13 @@ export function AdminSupportPage() {
6768
}, [setBreadcrumbs]);
6869

6970
const [page, setPage] = useState(1);
70-
const [statusFilter, setStatusFilter] = useState<AdminThreadStatus | "all">(
71-
"all"
72-
);
71+
const [view, setView] = useState<AdminThreadView>("inbox");
7372
const [searchInput, setSearchInput] = useState("");
7473
const [search, setSearch] = useState("");
7574
const limit = 30;
7675

7776
const { threads, pagination, threadsError, isThreadsLoading, mutateThreads } =
78-
useAdminSupportThreads(
79-
page,
80-
limit,
81-
statusFilter === "all" ? undefined : statusFilter,
82-
search || undefined
83-
);
77+
useAdminSupportThreads(page, limit, view, search || undefined);
8478
const { mutateUnreadCount } = useAdminSupportUnreadCount();
8579

8680
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
@@ -134,17 +128,17 @@ export function AdminSupportPage() {
134128
</form>
135129

136130
<Select
137-
value={statusFilter}
131+
value={view}
138132
onValueChange={(v) => {
139-
setStatusFilter(v as AdminThreadStatus | "all");
133+
setView(v as AdminThreadView);
140134
setPage(1);
141135
}}
142136
>
143137
<SelectTrigger className="w-36 h-10">
144138
<SelectValue />
145139
</SelectTrigger>
146140
<SelectContent>
147-
{STATUS_FILTERS.map((f) => (
141+
{VIEW_FILTERS.map((f) => (
148142
<SelectItem key={f.value} value={f.value}>
149143
{f.label}
150144
</SelectItem>
@@ -214,7 +208,7 @@ function ThreadList({
214208
}) {
215209
return (
216210
<div className="flex flex-col overflow-hidden lg:border-r">
217-
<div className="px-4 py-2">
211+
<div className="px-4 py-2 border-b">
218212
<Button className="w-full h-10" onClick={onCompose}>
219213
<PenSquare className="h-4 w-4 mr-2" />
220214
New thread
@@ -336,7 +330,7 @@ function ThreadDetail({
336330

337331
const [replyText, setReplyText] = useState("");
338332
const [isSending, setIsSending] = useState(false);
339-
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
333+
const [isUpdatingArchived, setIsUpdatingArchived] = useState(false);
340334

341335
// Reset the composer when switching threads.
342336
useEffect(() => {
@@ -373,16 +367,19 @@ function ThreadDetail({
373367
}
374368
};
375369

376-
const onStatusChange = async (status: AdminThreadStatus) => {
377-
setIsUpdatingStatus(true);
370+
const isArchived = thread.archivedAt !== null;
371+
const onToggleArchived = async () => {
372+
setIsUpdatingArchived(true);
378373
try {
379-
await updateAdminSupportThreadStatus(threadId, status);
374+
await updateAdminSupportThreadArchived(threadId, !isArchived);
380375
await mutateThread();
381376
onMutated();
382377
} catch (e) {
383-
toast.error(e instanceof Error ? e.message : "Failed to update status");
378+
toast.error(
379+
e instanceof Error ? e.message : "Failed to update archive state"
380+
);
384381
} finally {
385-
setIsUpdatingStatus(false);
382+
setIsUpdatingArchived(false);
386383
}
387384
};
388385

@@ -414,20 +411,24 @@ function ThreadDetail({
414411
</p>
415412
</div>
416413
</div>
417-
<Select
418-
value={thread.status}
419-
disabled={isUpdatingStatus}
420-
onValueChange={(v) => onStatusChange(v as AdminThreadStatus)}
414+
<Button
415+
variant="outline"
416+
className="h-10 w-36 shrink-0"
417+
onClick={onToggleArchived}
418+
disabled={isUpdatingArchived}
421419
>
422-
<SelectTrigger className="w-36 h-10 shrink-0">
423-
<SelectValue />
424-
</SelectTrigger>
425-
<SelectContent>
426-
<SelectItem value="open">Open</SelectItem>
427-
<SelectItem value="pending">Pending</SelectItem>
428-
<SelectItem value="closed">Closed</SelectItem>
429-
</SelectContent>
430-
</Select>
420+
{isArchived ? (
421+
<>
422+
<ArchiveRestore className="h-4 w-4 mr-2" />
423+
Unarchive
424+
</>
425+
) : (
426+
<>
427+
<Archive className="h-4 w-4 mr-2" />
428+
Archive
429+
</>
430+
)}
431+
</Button>
431432
</div>
432433

433434
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">

0 commit comments

Comments
 (0)