yiekheng 2b738383e4 feat: recurring reminders, fix QR pairing, account UX polish, tests
Reminders
- Add recurrence to wizard step 3 (None / Daily / Weekly+weekday picker /
  Monthly / Yearly). Build the RRULE client-side and thread it through
  the wizard URL state.
- Action stores rrule + scheduleKind="recurring" on insert.
- Bot reschedules the next occurrence after firing a recurring reminder
  using the existing rrule helpers in @cmbot/shared. One-off behavior
  unchanged.
- Add reminders.last_fired_at column to track last fire.

Pairing
- Move QR PNG out of the pg_notify payload (the 8000-byte limit was
  silently truncating it; QR never reached the web → "QR hang"). PNG
  now lives on whatsapp_accounts.last_qr_png; NOTIFY just signals
  {type: session.qr, accountId, ts}. Web fetches the bytes from a new
  read-only /api/qr/[accountId] route (allowed via middleware).
- handleStartPairing now stops any in-flight session before starting a
  fresh one — fixes Re-pair where session.start was a silent no-op and
  Baileys never re-emitted QR.
- Pair-live: countdown moved out from over the QR (it was overlapping
  the scan area); shown as a discrete progress bar above the QR.
- Add a "Save QR" download button.

Account detail page
- Pair / Unpair / Delete cards are themselves the trigger (form submit
  or DialogTrigger) — no inline buttons, whole card is clickable.
- Sync Groups Now card removed earlier; bot already auto-syncs.

Account list page
- Cards are the link target. A small floating Delete trigger (top-right
  trash icon) opens the destructive confirm dialog without blocking
  navigation on the rest of the card.

Tests
- recurrence.test.ts: 10 tests for buildRrule / kindFromRrule /
  describeRecurrence (incl. weekly day combos and BYDAY ordering).
- reminders.schema.test.ts: regression for the "Invalid datetime" bug —
  proves strict Zod .datetime() rejected luxon's offset ISO and the
  { offset: true } option accepts both forms.

Migration: 0004_next_prowler.sql
- whatsapp_accounts.last_qr_png (text)
- reminders.last_fired_at (timestamptz)

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

179 lines
6.9 KiB
TypeScript

import {
pgTable,
uuid,
text,
bigint,
integer,
boolean,
timestamp,
jsonb,
primaryKey,
uniqueIndex,
inet,
} from "drizzle-orm/pg-core";
export const operators = pgTable(
"operators",
{
id: uuid("id").primaryKey().defaultRandom(),
telegramUserId: bigint("telegram_user_id", { mode: "number" }).notNull(),
displayName: text("display_name").notNull(),
role: text("role").notNull().default("admin"),
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
telegramUserIdUnique: uniqueIndex("operators_telegram_user_id_uq").on(t.telegramUserId),
}),
);
export const whatsappAccounts = pgTable(
"whatsapp_accounts",
{
id: uuid("id").primaryKey().defaultRandom(),
operatorId: uuid("operator_id").notNull().references(() => operators.id),
label: text("label").notNull(),
phoneNumber: text("phone_number"),
status: text("status").notNull().default("pending"),
lastConnectedAt: timestamp("last_connected_at", { withTimezone: true }),
lastQrAt: timestamp("last_qr_at", { withTimezone: true }),
lastQrPng: text("last_qr_png"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
operatorLabelUnique: uniqueIndex("whatsapp_accounts_operator_label_uq").on(t.operatorId, t.label),
}),
);
export const whatsappGroups = pgTable(
"whatsapp_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id, { onDelete: "cascade" }),
waGroupJid: text("wa_group_jid").notNull(),
name: text("name").notNull(),
participantCount: integer("participant_count").notNull().default(0),
isArchived: boolean("is_archived").notNull().default(false),
lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
accountJidUnique: uniqueIndex("whatsapp_groups_account_jid_uq").on(t.accountId, t.waGroupJid),
}),
);
export const mediaFiles = pgTable("media_files", {
id: uuid("id").primaryKey().defaultRandom(),
operatorId: uuid("operator_id").notNull().references(() => operators.id),
filenameOriginal: text("filename_original").notNull(),
mimeType: text("mime_type").notNull(),
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
sha256: text("sha256").notNull(),
storagePath: text("storage_path").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const reminders = pgTable("reminders", {
id: uuid("id").primaryKey().defaultRandom(),
accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
scheduleKind: text("schedule_kind").notNull(),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
rrule: text("rrule"),
timezone: text("timezone").notNull(),
endsAt: timestamp("ends_at", { withTimezone: true }),
maxRuns: integer("max_runs"),
status: text("status").notNull().default("active"),
createdBy: uuid("created_by").notNull().references(() => operators.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
lastFiredAt: timestamp("last_fired_at", { withTimezone: true }),
});
export const reminderTargets = pgTable(
"reminder_targets",
{
reminderId: uuid("reminder_id").notNull().references(() => reminders.id, { onDelete: "cascade" }),
groupId: uuid("group_id").notNull().references(() => whatsappGroups.id),
position: integer("position").notNull().default(0),
},
(t) => ({
pk: primaryKey({ columns: [t.reminderId, t.groupId] }),
}),
);
export const reminderMessages = pgTable("reminder_messages", {
id: uuid("id").primaryKey().defaultRandom(),
reminderId: uuid("reminder_id").notNull().references(() => reminders.id, { onDelete: "cascade" }),
position: integer("position").notNull(),
kind: text("kind").notNull(),
textContent: text("text_content"),
mediaId: uuid("media_id").references(() => mediaFiles.id),
});
export const reminderRuns = pgTable("reminder_runs", {
id: uuid("id").primaryKey().defaultRandom(),
reminderId: uuid("reminder_id").notNull().references(() => reminders.id, { onDelete: "cascade" }),
firedAt: timestamp("fired_at", { withTimezone: true }).notNull().defaultNow(),
status: text("status").notNull(),
errorSummary: text("error_summary"),
});
export const reminderRunTargets = pgTable(
"reminder_run_targets",
{
runId: uuid("run_id").notNull().references(() => reminderRuns.id, { onDelete: "cascade" }),
groupId: uuid("group_id").notNull().references(() => whatsappGroups.id),
status: text("status").notNull(),
waMessageId: text("wa_message_id"),
error: text("error"),
latencyMs: integer("latency_ms"),
},
(t) => ({
pk: primaryKey({ columns: [t.runId, t.groupId] }),
}),
);
export const auditLog = pgTable("audit_log", {
id: uuid("id").primaryKey().defaultRandom(),
operatorId: uuid("operator_id").references(() => operators.id),
source: text("source").notNull(),
action: text("action").notNull(),
targetType: text("target_type"),
targetId: uuid("target_id"),
payload: jsonb("payload").notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const authSessions = pgTable("auth_sessions", {
id: uuid("id").primaryKey().defaultRandom(),
operatorId: uuid("operator_id").notNull().references(() => operators.id),
tokenHash: text("token_hash").notNull().unique(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(),
ipAddress: inet("ip_address"),
userAgent: text("user_agent"),
});
export const cacheEntries = pgTable("cache_entries", {
key: text("key").primaryKey(),
value: jsonb("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
});
export const rateLimitBuckets = pgTable("rate_limit_buckets", {
key: text("key").primaryKey(),
windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
count: integer("count").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
});
export type Operator = typeof operators.$inferSelect;
export type NewOperator = typeof operators.$inferInsert;
export type WhatsappAccount = typeof whatsappAccounts.$inferSelect;
export type NewWhatsappAccount = typeof whatsappAccounts.$inferInsert;
export type WhatsappGroup = typeof whatsappGroups.$inferSelect;
export type NewWhatsappGroup = typeof whatsappGroups.$inferInsert;
export type AuditLogEntry = typeof auditLog.$inferSelect;
export type NewAuditLogEntry = typeof auditLog.$inferInsert;