Wizard When-step and the per-section Edit When page now expose an optional "Pause sending by" hour. Fire time IS the implicit start, so the deadline is the only thing the operator sets. When the bot's fan-out hasn't finished by that hour (in the reminder's timezone) the run pauses for resume — that runtime gating lands in a later phase; this commit just persists the hour and threads it through the wizard. HourSelect splits hour and AM/PM into two side-by-side <select>s and emits a single 0..23 value. to12Hour / from12Hour are pure helpers covered by 11 round-trip tests. Dashboard adjustments: * "WhatsApp accounts" card now reads Connected / Unpaired / Total. * "Reminders" card reads Active / Paused / Ended / Total. * "Recent runs" stat card removed (the Recent activity section below shows the same info). * Activity rows show absolute timestamp with AM/PM and relative time in tandem. Accounts list: * The page-level <h1>Accounts</h1> is hidden on mobile (the top bar already shows it), matching the Dashboard pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
267 lines
9.7 KiB
TypeScript
267 lines
9.7 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),
|
|
});
|
|
// 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<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) — 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<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)],
|
|
});
|
|
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<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,
|
|
})),
|
|
};
|
|
}
|