yiekheng ea7d07b2c8 perf(db): composite index (account_id, name) + hide archived groups
Two related follow-ups for the 3 000+ groups-per-account scale path:

1. New B-tree index on whatsapp_groups (account_id, name) (migration
   0014). Covers the groups list page's
   `WHERE account_id=? ORDER BY name ASC LIMIT 200` query so PG
   streams pre-sorted from the index instead of pulling all rows
   then sorting. The unique (account_id, wa_group_jid) was the only
   prior B-tree on this table; it backed the WHERE prefix but not
   the ORDER BY.

2. listGroupsForAccount now filters `is_archived = false` in both
   the search and the no-search branch. Soft-archived groups
   (set when group-sync sees them disappear from the live
   participant list, or when an operator unpairs the account) used
   to leak into the wizard picker, letting operators pick a group
   the bot can no longer reach. Archived rows still exist in DB so
   reminders that target them keep working; a re-pair flips them
   back via the on-conflict upsert.

README "Deferred" entry for the composite index removed (it's
shipped). Search-as-you-type in the wizard picker stays deferred.

482 web + 88 bot tests still green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:57:17 +08:00

304 lines
12 KiB
TypeScript

import "server-only";
import { sql } from "drizzle-orm";
import { db } from "./db";
export async function getDashboardStats(operatorId: string) {
const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId),
});
// Reminders scoped to this operator's accounts. The previous
// findMany() with no filter leaked global counts across users — a
// brand-new user would see another operator's totals on the
// dashboard. INNER JOIN on whatsapp_accounts.operator_id keeps each
// user's view isolated.
const reminderRows = await db.execute(sql`
SELECT r.id, r.status
FROM reminders r
INNER JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId}
`);
const allReminders = reminderRows.rows as Array<{ id: string; status: string }>;
// LEFT JOIN so runs whose reminder has been deleted still appear. The
// ownership filter widens to: either the reminder still exists and the
// operator owns its account, OR the reminder is gone but the run row
// had a name snapshotted (history survives a delete by design).
const recentRuns = await db.execute(sql`
SELECT
rr.id,
rr.status,
rr.fired_at,
rr.reminder_id,
COALESCE(r.name, rr.reminder_name, '(deleted reminder)') AS name,
r.id IS NULL AS is_deleted
FROM reminder_runs rr
LEFT JOIN reminders r ON r.id = rr.reminder_id
LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId} OR r.id IS NULL
ORDER BY rr.fired_at DESC
LIMIT 3
`);
return {
connectedAccounts: accounts.filter((a) => a.status === "connected").length,
unpairedAccounts: accounts.filter((a) => a.status === "unpaired").length,
totalAccounts: accounts.length,
activeReminders: allReminders.filter((r) => r.status === "active").length,
pausedReminders: allReminders.filter((r) => r.status === "paused").length,
inactiveReminders: allReminders.filter((r) => r.status === "inactive").length,
totalReminders: allReminders.length,
recentRuns: recentRuns.rows as Array<{
id: string;
status: string;
fired_at: Date;
reminder_id: string | null;
name: string;
is_deleted: boolean;
}>,
};
}
export async function listAccounts(operatorId: string) {
// Show every account the operator owns, regardless of status. The
// status badge tells the user what state each row is in (Pending /
// Unpaired / Connected / Disconnected / etc), and the detail page
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
// by status produced phantom "I created an account but it's gone"
// bug reports.
// Earliest-added on top, newest at the bottom. Stable across renames
// (a label edit shouldn't reorder the list and confuse muscle memory)
// and matches how other admin tools order accounts that grow over time.
return db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId),
orderBy: (a, { asc }) => [asc(a.createdAt), asc(a.id)],
});
}
export async function getAccount(operatorId: string, accountId: string) {
return db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
}
export async function listGroupsForAccount(operatorId: string, accountId: string, q?: string) {
const account = await getAccount(operatorId, accountId);
if (!account) return null;
const trimmed = (q ?? "").trim();
// Hide archived groups from the picker by default. They're rows
// that disappeared from the live participant list (group deleted,
// bot kicked, etc.) but still have reminder_targets pointing at
// them — see the soft-archive flow in apps/bot/src/whatsapp/
// group-sync.ts. Surfacing archived rows here would let an
// operator pick a group the bot can't actually reach.
const rows = trimmed
? await db.execute(sql`
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
FROM whatsapp_groups
WHERE account_id = ${accountId}
AND is_archived = false
AND name % ${trimmed}
ORDER BY similarity(name, ${trimmed}) DESC
LIMIT 50
`)
: await db.execute(sql`
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
FROM whatsapp_groups
WHERE account_id = ${accountId}
AND is_archived = false
ORDER BY name ASC
LIMIT 200
`);
const groups = (rows.rows as Array<Record<string, unknown>>).map((r) => ({
id: r.id as string,
accountId: r.account_id as string,
waGroupJid: r.wa_group_jid as string,
name: r.name as string,
participantCount: Number(r.participant_count),
isArchived: r.is_archived as boolean,
lastSyncedAt: r.last_synced_at as Date,
}));
return { account, groups };
}
export async function getGroup(operatorId: string, groupId: string) {
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, groupId),
});
if (!group) return null;
const account = await getAccount(operatorId, group.accountId);
if (!account) return null;
return { group, account };
}
export async function listReminders(operatorId: string) {
// Fetch each reminder along with the snippet text from its first
// message and the comma-separated names+ids of its target groups.
// Both are needed by the /reminders search + filter UI.
const rows = await db.execute(sql`
SELECT
r.id, r.name, r.schedule_kind, r.scheduled_at, r.rrule, r.timezone, r.status,
r.created_at, r.account_id,
wa.label as account_label,
(SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count,
(
SELECT COALESCE(string_agg(wg.id::text, ',' ORDER BY rt.position), '')
FROM reminder_targets rt
JOIN whatsapp_groups wg ON wg.id = rt.group_id
WHERE rt.reminder_id = r.id
) as group_ids,
(
SELECT COALESCE(string_agg(wg.name, ' · ' ORDER BY rt.position), '')
FROM reminder_targets rt
JOIN whatsapp_groups wg ON wg.id = rt.group_id
WHERE rt.reminder_id = r.id
) as group_names,
(
SELECT rm.text_content
FROM reminder_messages rm
WHERE rm.reminder_id = r.id
ORDER BY rm.position ASC
LIMIT 1
) as first_text
FROM reminders r
JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId}
ORDER BY r.scheduled_at DESC NULLS LAST, r.created_at DESC
LIMIT 200
`);
// pg returns timestamp columns from raw `db.execute(sql)` as strings,
// not Date instances — coerce so callers (sort comparators, date
// formatters) can call .getTime() / .toLocaleDateString() safely.
const toDate = (v: unknown): Date | null => {
if (v == null) return null;
if (v instanceof Date) return v;
if (typeof v === "string" || typeof v === "number") return new Date(v);
return null;
};
return (rows.rows as Array<Record<string, unknown>>).map((r) => ({
id: r.id as string,
name: r.name as string,
scheduleKind: r.schedule_kind as string,
scheduledAt: toDate(r.scheduled_at),
rrule: (r.rrule as string | null) ?? null,
timezone: r.timezone as string,
status: r.status as string,
createdAt: toDate(r.created_at) ?? new Date(0),
accountId: r.account_id as string,
accountLabel: r.account_label as string,
groupCount: Number(r.group_count),
groupIds: ((r.group_ids as string | null) ?? "").split(",").filter(Boolean),
groupNames: (r.group_names as string | null) ?? "",
firstText: (r.first_text as string | null) ?? "",
}));
}
export interface ActivityRunRow {
id: string;
status: string;
firedAt: Date;
reminderId: string | null;
reminderName: string;
isDeleted: boolean;
archivedAt: Date | null;
}
export async function listActivityRuns(
operatorId: string,
opts: { archived?: boolean } = {},
): Promise<ActivityRunRow[]> {
// Mirrors the dashboard query but returns the full window (last 200) and
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
// has been deleted but history was preserved) in the list.
// The `archived` flag flips the visibility filter:
// false (default) — non-archived, non-skipped rows (skipped runs
// belong to the Archived tab now)
// true — archived rows OR skipped rows (they're treated
// as "history" rather than active outcomes)
const archivedClause = opts.archived
? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')`
: sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`;
const rows = await db.execute(sql`
SELECT
rr.id,
rr.status,
rr.fired_at,
rr.reminder_id,
rr.archived_at,
COALESCE(r.name, rr.reminder_name, '(deleted reminder)') AS name,
r.id IS NULL AS is_deleted
FROM reminder_runs rr
LEFT JOIN reminders r ON r.id = rr.reminder_id
LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE (wa.operator_id = ${operatorId} OR r.id IS NULL)
AND ${archivedClause}
ORDER BY rr.fired_at DESC
LIMIT 200
`);
return (rows.rows as Array<Record<string, unknown>>).map((r) => ({
id: r.id as string,
status: r.status as string,
firedAt: r.fired_at as Date,
reminderId: (r.reminder_id as string | null) ?? null,
reminderName: r.name as string,
isDeleted: Boolean(r.is_deleted),
archivedAt: (r.archived_at as Date | null) ?? null,
}));
}
export async function getReminderWithRuns(operatorId: string, reminderId: string) {
const reminder = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!reminder) return null;
// Verify operator owns the reminder via the account
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const targets = await db.execute(sql`
SELECT rt.group_id, wg.name as group_name
FROM reminder_targets rt
JOIN whatsapp_groups wg ON wg.id = rt.group_id
WHERE rt.reminder_id = ${reminderId}
ORDER BY rt.position
`);
const messages = await db.query.reminderMessages.findMany({
where: (m, { eq }) => eq(m.reminderId, reminderId),
orderBy: (m, { asc }) => [asc(m.position)],
});
// LEFT-JOIN aggregate counts in one round-trip so the detail page
// can render the paused banner with "X of Y groups delivered"
// without a per-run fan-out query. Counts are bigint in PG → cast
// to int so JSON marshalling stays lossless.
const runs = await db.execute(sql`
SELECT
rr.id,
rr.fired_at,
rr.status,
rr.error_summary,
COALESCE(SUM(CASE WHEN rt.status = 'sent' THEN 1 ELSE 0 END)::int, 0) AS sent,
COALESCE(COUNT(rt.id)::int, 0) AS total
FROM reminder_runs rr
LEFT JOIN reminder_run_targets rt ON rt.run_id = rr.id
WHERE rr.reminder_id = ${reminderId}
GROUP BY rr.id, rr.fired_at, rr.status, rr.error_summary
ORDER BY rr.fired_at DESC
LIMIT 20
`);
return {
reminder,
account,
targets: (targets.rows as Array<Record<string, unknown>>).map((r) => ({
groupId: r.group_id as string,
groupName: r.group_name as string,
})),
messages,
runs: (runs.rows as Array<Record<string, unknown>>).map((r) => ({
id: r.id as string,
firedAt: r.fired_at as Date,
status: r.status as string,
errorSummary: r.error_summary as string | null,
sent: r.sent as number,
total: r.total as number,
})),
};
}