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

228 lines
9.8 KiB
TypeScript

import { sql } from "drizzle-orm";
import {
pgTable,
uuid,
text,
bigint,
integer,
boolean,
timestamp,
jsonb,
primaryKey,
uniqueIndex,
index,
inet,
} from "drizzle-orm/pg-core";
export const operators = pgTable(
"operators",
{
id: uuid("id").primaryKey().defaultRandom(),
username: text("username").notNull(),
passwordHash: text("password_hash"),
displayName: text("display_name").notNull(),
// Reserved for future contact / recovery flows. Optional + nullable
// so today's operators don't have to backfill anything; admins can
// populate it from the Users page when we wire that up.
email: text("email"),
role: text("role").notNull().default("admin"),
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
usernameUnique: uniqueIndex("operators_username_uq").on(sql`lower(${t.username})`),
// Case-insensitive uniqueness only when an email IS set (NULLs
// remain freely insertable). Lets future flows look up operators
// by email without ambiguity.
emailUnique: uniqueIndex("operators_email_uq")
.on(sql`lower(${t.email})`)
.where(sql`${t.email} IS NOT NULL`),
}),
);
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),
}),
);
/**
* whatsapp_groups perf notes (production: 3 000+ rows per account):
* - account_jid_uq B-tree (account_id, wa_group_jid).
* Backs the on-conflict upsert during
* group-sync and every per-account
* WHERE-prefix scan.
* - whatsapp_groups_name_trgm GIN trgm index on `name` (migration
* 0002). Powers fuzzy search via the
* `name % term` operator in O(log n).
*/
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),
// Backs `WHERE account_id=? ORDER BY name ASC LIMIT 200` on the
// groups list page. Without this, PG falls back to the unique
// (account_id, wa_group_jid) index for the WHERE clause and then
// does an explicit sort on `name` — fine at small scale, slow
// when an operator has 3 000+ groups. Drizzle import is `index`,
// declared in this same file's import block.
accountNameIdx: index("whatsapp_groups_account_name_idx").on(
t.accountId,
t.name,
),
}),
);
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 }),
// Delivery window (operator timezone). End hour is enforced at runtime
// by fire-reminder when window enforcement lands; start hour is documented
// here but not gated in v1.
// 24 is the "no deadline" sentinel — it's the off-by-default state so a
// reminder created without the operator explicitly opting into "Pause
// sending by" stays unbounded.
deliveryWindowStartHour: integer("delivery_window_start_hour").notNull().default(6),
deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(24),
});
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(),
// Nullable + ON DELETE SET NULL: deleting a reminder must NOT erase its
// run history. The accompanying snapshot fields below preserve enough
// context to keep history rows readable.
reminderId: uuid("reminder_id").references(() => reminders.id, { onDelete: "set null" }),
reminderName: text("reminder_name"),
firedAt: timestamp("fired_at", { withTimezone: true }).notNull().defaultNow(),
status: text("status").notNull(),
errorSummary: text("error_summary"),
// Soft-archive: non-null hides the row from the default activity
// listing but keeps it queryable under a dedicated "Archived" filter.
// The user can restore (unarchive) later or hard-delete from there.
archivedAt: timestamp("archived_at", { withTimezone: true }),
});
export const reminderRunTargets = pgTable("reminder_run_targets", {
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id").notNull().references(() => reminderRuns.id, { onDelete: "cascade" }),
// Nullable + ON DELETE SET NULL: unpair/delete-account wipes the
// associated whatsapp_groups rows but the historical fan-out record
// ("we tried to send to this group, here's the result") must survive.
// The accompanying snapshot field below preserves the readable label.
groupId: uuid("group_id").references(() => whatsappGroups.id, { onDelete: "set null" }),
groupLabel: text("group_label"),
status: text("status").notNull(),
waMessageId: text("wa_message_id"),
error: text("error"),
latencyMs: integer("latency_ms"),
});
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;