yiekheng 704bc5e788 feat(activity): swipe-to-archive/delete; quieter send-test toast
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>
2026-05-10 12:20:05 +08:00

106 lines
3.6 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { eq, sql } from "drizzle-orm";
import { reminderRuns } from "@cmbot/db";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { checkRateLimit } from "@/lib/rate-limit";
async function rateLimit(key: string, opts: { max?: number; windowSec?: number } = {}) {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown";
const r = await checkRateLimit(`${key}:${ip}`, {
max: opts.max ?? 5,
windowSec: opts.windowSec ?? 60,
});
if (r.limited) throw new Error("Too many requests");
}
/**
* Verify the run belongs to the seeded operator (or is an orphan from a
* deleted reminder, which the dashboard considers shared history). Returns
* the run's id when ownership checks out, otherwise null.
*/
async function checkRunOwnership(runId: string): Promise<string | null> {
const op = await getSeededOperator();
const rows = await db.execute<{ id: string }>(sql`
SELECT rr.id
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 rr.id = ${runId}
AND (wa.operator_id = ${op.id} OR r.id IS NULL)
LIMIT 1
`);
return rows.rows[0]?.id ?? null;
}
/**
* Wipe the operator's reminder run history. Operators only see runs whose
* underlying reminder is still owned by them PLUS orphan runs (whose
* reminder was deleted) — the dashboard query mirrors this. We delete
* both sets so "clear history" feels exhaustive.
*/
export async function clearHistoryAction(): Promise<void> {
await rateLimit("clear-history");
const op = await getSeededOperator();
await db.execute(sql`
DELETE FROM ${reminderRuns}
WHERE id IN (
SELECT rr.id
FROM ${reminderRuns} 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 = ${op.id} OR r.id IS NULL
)
`);
revalidatePath("/");
revalidatePath("/reminders");
}
/** Soft-archive one run. Hidden from the default activity list afterwards. */
export async function archiveRunAction(formData: FormData): Promise<void> {
await rateLimit("archive-run", { max: 30, windowSec: 60 });
const runId = formData.get("runId");
if (typeof runId !== "string") return;
const ownedId = await checkRunOwnership(runId);
if (!ownedId) return;
await db
.update(reminderRuns)
.set({ archivedAt: new Date() })
.where(eq(reminderRuns.id, ownedId));
revalidatePath("/");
revalidatePath("/activity");
}
/** Move a previously-archived run back to the default activity list. */
export async function unarchiveRunAction(formData: FormData): Promise<void> {
await rateLimit("unarchive-run", { max: 30, windowSec: 60 });
const runId = formData.get("runId");
if (typeof runId !== "string") return;
const ownedId = await checkRunOwnership(runId);
if (!ownedId) return;
await db
.update(reminderRuns)
.set({ archivedAt: null })
.where(eq(reminderRuns.id, ownedId));
revalidatePath("/");
revalidatePath("/activity");
}
/** Hard-delete one run. Cascades through reminder_run_targets via FK. */
export async function deleteRunAction(formData: FormData): Promise<void> {
await rateLimit("delete-run", { max: 30, windowSec: 60 });
const runId = formData.get("runId");
if (typeof runId !== "string") return;
const ownedId = await checkRunOwnership(runId);
if (!ownedId) return;
await db.delete(reminderRuns).where(eq(reminderRuns.id, ownedId));
revalidatePath("/");
revalidatePath("/activity");
}