Two unrelated bits the user asked for in the same breath:
1. Activity row swipe-to-reveal actions
----------------------------------------
On the mobile activity tab, drag a row left to reveal an Archive
button (Restore when already archived) and a Delete button. Past a
60 px threshold the shelf locks open; below that it springs back.
Tapping anywhere outside an open row closes it. Desktop keeps a
table layout but gains the same two row-level icon-buttons in a
new Actions column, since hover-then-discover is more natural with
a mouse than a swipe.
- New `<SwipeableRow>` (apps/web/src/components/swipeable-row.tsx)
— pointer-events only (no third-party gesture lib), 130 lines.
The drag math lives in a pure helper `computeSwipeNext` so it's
unit-testable without a DOM.
- Migration 0007 adds `reminder_runs.archived_at timestamptz`
(null = visible by default, non-null = archived). Soft-archive
keeps the row queryable under a new "Archived" filter tab; hard
Delete drops the row entirely (run_targets cascade via FK).
- Server actions: `archiveRunAction` / `unarchiveRunAction` /
`deleteRunAction`. Each rate-limits to 30/min/IP. Ownership
check piggybacks on the same operator-or-orphan rule the
activity query already uses.
- `listActivityRuns(operatorId, { archived })` extended to filter
in or out of the archived window. Default is archived: false so
the existing tabs (All / Success / Partial / Failed / Skipped)
keep showing only live runs.
- Tests
* `swipeable-row.test.tsx` — 6 unit tests covering the drag math
(clamp at 0 / -SHELF_WIDTH, snap-to-closed below threshold,
snap-to-open at or past threshold, snap math respects the
previous offset) plus 2 SSR markup contracts (data-testid /
aria-hidden / starts at translateX(0px) / data-state="closed").
* Total web suite: 154 passing (was 146).
2. Send-test toast text trim
----------------------------------------
"Sent ✓ — check the WhatsApp group." → "Sent ✓". The trailing
note told the user something they could already see (they're the
one who clicked Send Test on a specific group). Less noise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
7.7 KiB
TypeScript
187 lines
7.7 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(),
|
|
// 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;
|