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), }); // All reminder rows so the dashboard can show active/total in one query. // Status enum today is active / ended (paused will join in a later phase). const allReminders = await db.query.reminders.findMany(); // 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, endedReminders: allReminders.filter((r) => r.status === "ended").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. return db.query.whatsappAccounts.findMany({ where: (a, { eq }) => eq(a.operatorId, operatorId), orderBy: (a, { asc }) => [asc(a.label)], }); } 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(); 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 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} ORDER BY name ASC LIMIT 200 `); const groups = (rows.rows as Array>).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>).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 { // 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) โ€” only non-archived rows // true โ€” only archived rows (for the Archived tab) const archivedClause = opts.archived ? sql`rr.archived_at IS NOT NULL` : sql`rr.archived_at IS NULL`; 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>).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)], }); const runs = await db.execute(sql` SELECT id, fired_at, status, error_summary FROM reminder_runs WHERE reminder_id = ${reminderId} ORDER BY fired_at DESC LIMIT 20 `); return { reminder, account, targets: (targets.rows as Array>).map((r) => ({ groupId: r.group_id as string, groupName: r.group_name as string, })), messages, runs: (runs.rows as Array>).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, })), }; }