From 9437df74eed7b4ddedb407b9cf2ac61a14d8f72e Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 00:27:33 +0800 Subject: [PATCH] feat(web): split Add Account from Pair; add Unpair/Re-pair/Delete actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the account lifecycle to match how operators actually want to work the system: - Add Account → creates a row with status='unpaired'. No QR yet; the operator lands on the detail page. - Pair / Re-pair → transitions an unpaired account to status='pending' and opens the live QR flow. Works for first-time pair AND for re-pair of an account that was previously unpaired. - Unpair → asks the bot to stop the live Baileys session and clean session files; sets status='unpaired' but KEEPS the row (and its reminders) so the operator can re-pair without retyping anything. - Delete → permanently removes the account and cascades to its groups, reminders, run history. Schema: - whatsapp_groups.account_id and reminders.account_id now have ON DELETE CASCADE so deleting an account fans out cleanly. UI: - /accounts list shows everything except the transient 'pending' state. - /accounts/[id] shows state-aware buttons: Pair (when unpaired/banned/ disconnected), Sync + Unpair (when connected), Delete (always). - /accounts/new is now an "Add Account" form (label only). Other fixes: - next.config.ts: allowedDevOrigins includes 192.168.0.253 + test/rexwa subdomains so Server Actions work across the LAN. - packages/shared/src/rrule.ts: rrule@2.8.1 has no exports field and ships ESM that some bundlers can't resolve via default OR named import. Use createRequire to bridge — works under both NodeNext (bot runtime) and Turbopack (web SSR). --- apps/bot/src/ipc/unpair-handler.ts | 39 +- apps/web/next.config.ts | 3 + apps/web/src/actions/accounts.ts | 109 +- apps/web/src/app/accounts/[id]/page.tsx | 184 ++- apps/web/src/app/accounts/new/page.tsx | 4 +- apps/web/src/app/reminders/new/page.tsx | 38 + apps/web/src/components/pair-form.tsx | 10 +- .../reminder-wizard/compose-form-client.tsx | 252 +++++ .../reminder-wizard/groups-form-client.tsx | 183 +++ .../reminder-wizard/review-submit-client.tsx | 93 ++ .../reminder-wizard/step-account.tsx | 91 ++ .../reminder-wizard/step-compose.tsx | 59 + .../reminder-wizard/step-groups.tsx | 163 +++ .../reminder-wizard/step-review.tsx | 216 ++++ .../components/reminder-wizard/step-when.tsx | 61 + .../components/reminder-wizard/stepper.tsx | 112 ++ .../reminder-wizard/when-form-client.tsx | 176 +++ apps/web/src/lib/queries.ts | 7 +- .../db/migrations/0003_messy_bruce_banner.sql | 15 + .../db/migrations/meta/0003_snapshot.json | 1001 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 4 +- packages/shared/src/rrule.ts | 12 +- session | 1 + 24 files changed, 2723 insertions(+), 117 deletions(-) create mode 100644 apps/web/src/app/reminders/new/page.tsx create mode 100644 apps/web/src/components/reminder-wizard/compose-form-client.tsx create mode 100644 apps/web/src/components/reminder-wizard/groups-form-client.tsx create mode 100644 apps/web/src/components/reminder-wizard/review-submit-client.tsx create mode 100644 apps/web/src/components/reminder-wizard/step-account.tsx create mode 100644 apps/web/src/components/reminder-wizard/step-compose.tsx create mode 100644 apps/web/src/components/reminder-wizard/step-groups.tsx create mode 100644 apps/web/src/components/reminder-wizard/step-review.tsx create mode 100644 apps/web/src/components/reminder-wizard/step-when.tsx create mode 100644 apps/web/src/components/reminder-wizard/stepper.tsx create mode 100644 apps/web/src/components/reminder-wizard/when-form-client.tsx create mode 100644 packages/db/migrations/0003_messy_bruce_banner.sql create mode 100644 packages/db/migrations/meta/0003_snapshot.json create mode 100644 session diff --git a/apps/bot/src/ipc/unpair-handler.ts b/apps/bot/src/ipc/unpair-handler.ts index 964a5c0..d6b4960 100644 --- a/apps/bot/src/ipc/unpair-handler.ts +++ b/apps/bot/src/ipc/unpair-handler.ts @@ -1,7 +1,5 @@ -import { eq } from "drizzle-orm"; import { rm } from "node:fs/promises"; import { join } from "node:path"; -import { whatsappAccounts } from "@cmbot/db"; import { db } from "../db.js"; import { env } from "../env.js"; import { sessionManager } from "../whatsapp/session-manager.js"; @@ -9,27 +7,26 @@ import { writeAuditLog } from "../audit.js"; import { pgNotifyWeb } from "./notify.js"; import { logger } from "../logger.js"; +/** + * Unpair handler: stop the live Baileys session and remove session files. + * The web's unpair action deletes the account row before notifying us, + * so we expect the row to be gone by the time we run. Audit log uses a + * null operator since the row is no longer queryable. + */ export async function handleUnpair(accountId: string): Promise { - const account = await db.query.whatsappAccounts.findFirst({ - where: (a, { eq }) => eq(a.id, accountId), - }); - if (!account) { - logger.warn({ accountId }, "unpair: account row missing"); - return; - } await sessionManager.stop(accountId); await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true }); - await db - .update(whatsappAccounts) - .set({ status: "logged_out", phoneNumber: null }) - .where(eq(whatsappAccounts.id, accountId)); - await writeAuditLog(db, { - operatorId: account.operatorId, - source: "web", - action: "account.unpaired", - targetType: "whatsapp_account", - targetId: accountId, - payload: { label: account.label }, - }); + try { + await writeAuditLog(db, { + operatorId: null, + source: "web", + action: "account.unpaired", + targetType: "whatsapp_account", + targetId: accountId, + payload: {}, + }); + } catch (err) { + logger.warn({ err, accountId }, "unpair: audit log failed (non-fatal)"); + } await pgNotifyWeb({ type: "session.disconnected", accountId }); } diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 03dcaab..d26a944 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -14,6 +14,9 @@ const nextConfig: NextConfig = { reactStrictMode: true, output: "standalone", outputFileTracingRoot: workspaceRoot, + // Allow Server Actions and dev HMR from the LAN host (phone testing). + // Tighten before exposing publicly via the reverse proxy. + allowedDevOrigins: ["192.168.0.253", "test.04080616.xyz", "rexwa.04080616.xyz"], experimental: { typedRoutes: true, }, diff --git a/apps/web/src/actions/accounts.ts b/apps/web/src/actions/accounts.ts index c1f7fe7..813554a 100644 --- a/apps/web/src/actions/accounts.ts +++ b/apps/web/src/actions/accounts.ts @@ -5,7 +5,7 @@ import { redirect } from "next/navigation"; import { headers } from "next/headers"; import { z } from "zod"; import { eq } from "drizzle-orm"; -import { whatsappAccounts } from "@cmbot/db"; +import { whatsappAccounts, whatsappGroups } from "@cmbot/db"; import { db } from "@/lib/db"; import { getSeededOperator } from "@/lib/operator"; import { pgNotifyBot } from "@/lib/notify"; @@ -21,7 +21,7 @@ async function rateLimit(key: string) { if (r.limited) throw new Error("Too many requests"); } -const pairSchema = z.object({ +const addAccountSchema = z.object({ label: z .string() .trim() @@ -29,72 +29,119 @@ const pairSchema = z.object({ .max(60, "Label too long (max 60)"), }); -export type PairResult = +export type AddAccountResult = | { ok: true; accountId: string } | { ok: false; error: string }; -export async function pairAccountAction( +/** + * Step 1 of the lifecycle: create an account row with status='unpaired'. + * No QR scan yet. Caller redirects to /accounts/[id] where the operator + * sees the account detail with a "Pair Now" button. + */ +export async function addAccountAction( _prev: unknown, formData: FormData, -): Promise { - await rateLimit("pair"); - const parsed = pairSchema.safeParse({ label: formData.get("label") }); +): Promise { + await rateLimit("add-account"); + const parsed = addAccountSchema.safeParse({ label: formData.get("label") }); if (!parsed.success) { - return { - ok: false, - error: parsed.error.issues[0]?.message ?? "Invalid label", - }; + return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid label" }; } const op = await getSeededOperator(); const existing = await db.query.whatsappAccounts.findFirst({ - where: (a, { eq, and }) => - and(eq(a.operatorId, op.id), eq(a.label, parsed.data.label)), + where: (a, { eq, and }) => and(eq(a.operatorId, op.id), eq(a.label, parsed.data.label)), }); - if (existing && existing.status === "connected") { + if (existing) { return { ok: false, - error: `"${parsed.data.label}" is already connected. Unpair first.`, + error: `An account labelled "${parsed.data.label}" already exists.`, }; } + const [created] = await db + .insert(whatsappAccounts) + .values({ operatorId: op.id, label: parsed.data.label, status: "unpaired" }) + .returning({ id: whatsappAccounts.id }); + revalidatePath("/accounts"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + redirect(`/accounts/${created!.id}` as any); +} - let accountId = existing?.id; - if (!accountId) { - const [created] = await db - .insert(whatsappAccounts) - .values({ - operatorId: op.id, - label: parsed.data.label, - status: "pending", - }) - .returning({ id: whatsappAccounts.id }); - accountId = created!.id; +/** + * Trigger pair / re-pair for an existing account. Transitions the row to + * status='pending' and asks the bot to open a Baileys session. Operator + * lands on the live QR page. + */ +export async function pairAccountAction(formData: FormData): Promise { + await rateLimit("pair"); + const accountId = formData.get("accountId"); + if (typeof accountId !== "string") return; + const op = await getSeededOperator(); + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), + }); + if (!account) return; + if (account.status === "connected") { + // Already connected — bounce to detail + // eslint-disable-next-line @typescript-eslint/no-explicit-any + redirect(`/accounts/${accountId}` as any); } - + await db + .update(whatsappAccounts) + .set({ status: "pending", lastQrAt: new Date() }) + .where(eq(whatsappAccounts.id, accountId)); await pgNotifyBot({ type: "account.start_pairing", accountId }); revalidatePath("/accounts"); + revalidatePath(`/accounts/${accountId}`); // eslint-disable-next-line @typescript-eslint/no-explicit-any redirect(`/accounts/${accountId}/pairing` as any); } +/** + * Unpair: stop the Baileys session and clear session files via the bot, + * but KEEP the account row (status -> 'unpaired') so the operator can + * re-pair without retyping the label or losing any references. + */ export async function unpairAccountAction(formData: FormData): Promise { await rateLimit("unpair"); const accountId = formData.get("accountId"); if (typeof accountId !== "string") return; const op = await getSeededOperator(); const account = await db.query.whatsappAccounts.findFirst({ - where: (a, { eq, and }) => - and(eq(a.id, accountId), eq(a.operatorId, op.id)), + where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), }); if (!account) return; await pgNotifyBot({ type: "account.unpair", accountId }); - // Optimistic UI — bot will overwrite status via the same column await db .update(whatsappAccounts) - .set({ status: "logged_out", phoneNumber: null }) + .set({ status: "unpaired", phoneNumber: null }) .where(eq(whatsappAccounts.id, accountId)); + // Wipe synced groups too — they belong to a different WA login now. + await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId)); revalidatePath("/accounts"); revalidatePath(`/accounts/${accountId}`); // eslint-disable-next-line @typescript-eslint/no-explicit-any + redirect(`/accounts/${accountId}` as any); +} + +/** + * Permanently delete an account, its groups, reminders and run history + * via the cascade FKs added in migration 0003. + */ +export async function deleteAccountAction(formData: FormData): Promise { + await rateLimit("delete-account"); + const accountId = formData.get("accountId"); + if (typeof accountId !== "string") return; + const op = await getSeededOperator(); + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), + }); + if (!account) return; + // Stop any live session / clean session files first. + await pgNotifyBot({ type: "account.unpair", accountId }); + // Cascade FKs handle groups, reminders, runs, run_targets, messages. + await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId)); + revalidatePath("/accounts"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any redirect("/accounts" as any); } diff --git a/apps/web/src/app/accounts/[id]/page.tsx b/apps/web/src/app/accounts/[id]/page.tsx index 256ee4d..04618a9 100644 --- a/apps/web/src/app/accounts/[id]/page.tsx +++ b/apps/web/src/app/accounts/[id]/page.tsx @@ -9,6 +9,8 @@ import { CalendarIcon, TagIcon, DatabaseIcon, + PowerIcon, + PowerOffIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -30,7 +32,12 @@ import { import { AccountStatusBadge } from "@/components/account-status-badge"; import { getSeededOperator } from "@/lib/operator"; import { getAccount } from "@/lib/queries"; -import { syncGroupsAction, unpairAccountAction } from "@/actions/accounts"; +import { + syncGroupsAction, + unpairAccountAction, + pairAccountAction, + deleteAccountAction, +} from "@/actions/accounts"; interface AccountDetailPageProps { params: Promise<{ id: string }>; @@ -72,50 +79,124 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro {/* Actions */}
- {/* Groups */} - - -
-
- + {/* Pair / Re-pair — visible when not currently connected */} + {account.status !== "connected" && ( + + +
+
+ +
+
+

+ {account.status === "unpaired" ? "Pair WhatsApp" : "Re-pair WhatsApp"} +

+

+ Show a QR code so this account can connect to WhatsApp +

+
-
-

Groups

-

View synced WhatsApp groups

-
-
- - - +
+ + +
+ + + )} - {/* Sync */} - - -
-
- -
-
-

Sync Groups Now

-

- Fetch latest groups from WhatsApp -

-
-
-
- - -
-
-
+ {/* Groups + Sync — visible when connected */} + {account.status === "connected" && ( + <> + + +
+
+ +
+
+

Groups

+

View synced WhatsApp groups

+
+
+ +
+
- {/* Unpair */} + + +
+
+ +
+
+

Sync Groups Now

+

+ Fetch latest groups from WhatsApp +

+
+
+
+ + +
+
+
+ + + +
+
+ +
+
+

Unpair

+

+ Disconnect from WhatsApp; keep the account so you can re-pair later +

+
+
+ + + + + + + Unpair this account? + + {account.label} will disconnect from WhatsApp and + scheduled reminders using it will stop firing until you re-pair. + The account itself is kept; reminders and other data are not deleted. + + + +
+ + +
+
+
+
+
+
+ + )} + + {/* Delete — always available */}
@@ -123,33 +204,34 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
-

Unpair Account

+

Delete Account

- Disconnect and remove this account + Remove the account and all its reminders, groups, and history

- Unpair account? + Delete this account permanently? - This will disconnect {account.label} from cm WhatsApp Bot. - Any scheduled reminders using this account will stop firing. This action - cannot be undone. + {account.label} will be removed along with its + synced groups, scheduled reminders, and all run history. This cannot be + undone. -
+
diff --git a/apps/web/src/app/accounts/new/page.tsx b/apps/web/src/app/accounts/new/page.tsx index 9ff7e45..cb44dce 100644 --- a/apps/web/src/app/accounts/new/page.tsx +++ b/apps/web/src/app/accounts/new/page.tsx @@ -28,9 +28,9 @@ export default function NewAccountPage() {
-

Pair New Account

+

Add Account

- Link a WhatsApp number to start scheduling reminders. + Create a new account slot. You'll pair the WhatsApp number on the next screen.

diff --git a/apps/web/src/app/reminders/new/page.tsx b/apps/web/src/app/reminders/new/page.tsx new file mode 100644 index 0000000..7d66dcf --- /dev/null +++ b/apps/web/src/app/reminders/new/page.tsx @@ -0,0 +1,38 @@ +import { notFound } from "next/navigation"; +import { Stepper } from "@/components/reminder-wizard/stepper"; +import { StepAccount } from "@/components/reminder-wizard/step-account"; +import { StepGroups } from "@/components/reminder-wizard/step-groups"; +import { StepCompose } from "@/components/reminder-wizard/step-compose"; +import { StepWhen } from "@/components/reminder-wizard/step-when"; +import { StepReview } from "@/components/reminder-wizard/step-review"; + +interface PageProps { + searchParams: Promise<{ + step?: string; + accountId?: string; + groupIds?: string; + text?: string; + mediaId?: string; + caption?: string; + scheduledAt?: string; + groupId?: string; + }>; +} + +export default async function NewReminderPage({ searchParams }: PageProps) { + const sp = await searchParams; + const step = Number(sp.step ?? "1"); + if (![1, 2, 3, 4, 5].includes(step)) notFound(); + + return ( +
+

New Reminder

+ + {step === 1 && } + {step === 2 && } + {step === 3 && } + {step === 4 && } + {step === 5 && } +
+ ); +} diff --git a/apps/web/src/components/pair-form.tsx b/apps/web/src/components/pair-form.tsx index 80787be..c0501c3 100644 --- a/apps/web/src/components/pair-form.tsx +++ b/apps/web/src/components/pair-form.tsx @@ -6,12 +6,12 @@ import { ArrowRightIcon, Loader2Icon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { pairAccountAction, type PairResult } from "@/actions/accounts"; +import { addAccountAction, type AddAccountResult } from "@/actions/accounts"; -const initialState: PairResult = { ok: true, accountId: "" }; +const initialState: AddAccountResult = { ok: true, accountId: "" }; export function PairForm() { - const [state, action, isPending] = useActionState(pairAccountAction, initialState); + const [state, action, isPending] = useActionState(addAccountAction, initialState); return (
@@ -56,11 +56,11 @@ export function PairForm() { {isPending ? ( <> - Starting… + Adding… ) : ( <> - Start Pairing + Add Account )} diff --git a/apps/web/src/components/reminder-wizard/compose-form-client.tsx b/apps/web/src/components/reminder-wizard/compose-form-client.tsx new file mode 100644 index 0000000..f2c8c29 --- /dev/null +++ b/apps/web/src/components/reminder-wizard/compose-form-client.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useState, useRef, useActionState } from "react"; +import { useRouter } from "next/navigation"; +import { + PaperclipIcon, + ImageIcon, + FileIcon, + XIcon, + AlertCircleIcon, + UploadIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { uploadMediaAction } from "@/actions/media"; +import { cn } from "@/lib/utils"; + +interface PassThroughParams { + scheduledAt?: string; +} + +interface ComposeFormClientProps { + accountId: string; + groupIds: string; + initialText: string; + initialMediaId?: string; + initialCaption?: string; + passThroughParams: PassThroughParams; +} + +export function ComposeFormClient({ + accountId, + groupIds, + initialText, + initialMediaId, + initialCaption, + passThroughParams, +}: ComposeFormClientProps) { + const router = useRouter(); + const fileInputRef = useRef(null); + + const [text, setText] = useState(initialText); + const [caption, setCaption] = useState(initialCaption ?? ""); + const [mediaId, setMediaId] = useState(initialMediaId); + const [mediaFilename, setMediaFilename] = useState(undefined); + const [mediaMimeType, setMediaMimeType] = useState(undefined); + const [previewUrl, setPreviewUrl] = useState(undefined); + const [dragOver, setDragOver] = useState(false); + const [localError, setLocalError] = useState(null); + const [uploading, setUploading] = useState(false); + + const [uploadState, uploadAction] = useActionState(uploadMediaAction, null); + + // Show server-side upload errors + const uploadError = + uploadState && !uploadState.ok ? uploadState.error : null; + + async function handleFile(file: File) { + setLocalError(null); + setUploading(true); + const fd = new FormData(); + fd.append("file", file); + const result = await uploadMediaAction(null, fd); + setUploading(false); + if (result.ok) { + setMediaId(result.mediaId); + setMediaFilename(result.filename); + setMediaMimeType(result.mimeType); + if (result.mimeType.startsWith("image/")) { + const url = URL.createObjectURL(file); + setPreviewUrl(url); + } else { + setPreviewUrl(undefined); + } + } else { + setLocalError(result.error); + } + } + + function handleFileInput(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) handleFile(file); + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) handleFile(file); + } + + function removeMedia() { + setMediaId(undefined); + setMediaFilename(undefined); + setMediaMimeType(undefined); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + setPreviewUrl(undefined); + } + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + + function handleContinue() { + if (!text.trim() && !mediaId) { + setLocalError("Add a message or attach a file."); + return; + } + const sp = new URLSearchParams({ + step: "4", + accountId, + groupIds, + }); + if (text.trim()) sp.set("text", text.trim()); + if (mediaId) sp.set("mediaId", mediaId); + if (caption.trim()) sp.set("caption", caption.trim()); + if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.push(`/reminders/new?${sp.toString()}` as any); + } + + const isImage = mediaMimeType?.startsWith("image/"); + + return ( +
+ {/* Body text */} +
+ +