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>
106 lines
3.6 KiB
TypeScript
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");
|
|
}
|