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>
304 lines
12 KiB
TypeScript
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,
|
|
})),
|
|
};
|
|
}
|