From b0e8d15273851e4f869800e3dae2244fc933c73c Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 5 Jun 2026 03:11:26 +0000 Subject: [PATCH] Stop tracking .scratch/ (local planning scratchpad) --- .gitignore | 3 ++ .scratch/close-hardening/design.md | 73 ----------------------------- .scratch/folder-lifecycle/design.md | 73 ----------------------------- .scratch/forward-command/design.md | 54 --------------------- 4 files changed, 3 insertions(+), 200 deletions(-) delete mode 100644 .scratch/close-hardening/design.md delete mode 100644 .scratch/folder-lifecycle/design.md delete mode 100644 .scratch/forward-command/design.md diff --git a/.gitignore b/.gitignore index abad8ce..a5e01b3 100644 --- a/.gitignore +++ b/.gitignore @@ -48,5 +48,8 @@ cursor.yml .claude/ +# Local planning/issue-tracker scratchpad — specs & PRDs stay on disk, not in git +.scratch/ + CLAUDE.md *.bak* diff --git a/.scratch/close-hardening/design.md b/.scratch/close-hardening/design.md deleted file mode 100644 index dda43da..0000000 --- a/.scratch/close-hardening/design.md +++ /dev/null @@ -1,73 +0,0 @@ -# Close-hardening: stop mid-close restarts from orphaning channels - -## Problem (observed) -Ticket #18 was closed (transcript saved, `status: closed`) but its Discord channel -was never deleted — it lingers as an orphan. Root cause: the final channel delete -is a deferred `setTimeout`, and a container restart during the delay drops it. - -Evidence: ticket #18 in Mongo is `status: "closed"` with -`discordThreadId: "1512204690631430144"` still pointing at the live channel, while -properly-closed tickets (#17, #12, #11, #10) are `status: closed` + `discordThreadId: null`. - -## Three underlying defects -1. **Deferred delete is cancellable / fragile.** - - Button path (`handlers/buttons.js` `runFinalClose:471`): - `trackTimeout(setTimeout(() => channel.delete(), 5000))`. On SIGTERM, - `handleShutdown` (`broccolini-discord.js:276-279`) `clearTimeout`s every tracked - timeout → the delete never fires. A redeploy in the 5 s window orphans the channel. - - Slash path (`handlers/commands/close.js` `finalizeForceClose:89-93`): a *plain* - `setTimeout` (not tracked) — survives SIGTERM but dies on hard exit/SIGKILL, and - there is no reconciliation either way. -2. **Inconsistent DB writes between the two paths.** - - Button path sets `{ discordThreadId: null, status: 'closed' }` (buttons.js:447-450). - - Slash path sets only `{ status: 'closed' }` (close.js:73-76), leaving `discordThreadId`. - So an orphan may have `discordThreadId` null OR still-set — no single signal. -3. **No reconciliation for "closed but channel still exists."** - `reconcileDeletedTicketChannels` only handles the opposite direction (DB open, - channel gone). Nothing heals a closed ticket whose channel survived. - -## Goals -- A restart at any moment during close must not permanently orphan a channel. -- Both close paths leave identical, unambiguous DB state. -- A self-healing sweep finishes any delete a restart interrupted. - -## Approach (IMPLEMENTED — uses the existing pendingDelete mechanism) -Discovery during implementation: the codebase **already has** the restart-survival -machinery — the `pendingDelete` flag (`models.js`) + `resumePendingDeletes(client)` -called once from the `ready` handler (`broccolini-discord.js:231`). The **auto-close** -path uses it correctly; the **button** and **slash** paths simply did not participate -(bare `setTimeout(channel.delete())`, never setting `pendingDelete`). That omission is -the entire bug. So the fix is to make all three paths share one guarded delete — NOT to -add a new reconcile job. - -1. **Shared helper `scheduleTicketChannelDelete(channel, gmailThreadId)`** in - `services/tickets.js`: after a 5 s grace delay, `enqueueDelete(channel)` (queue-routed, - honoring Hard Rule #3 — the old bare `channel.delete()` bypassed the queue) then unset - `pendingDelete`. Wrapped in `trackTimeout`. -2. **Each close path sets `pendingDelete: true` and keeps `discordThreadId` populated** - before scheduling, so a restart in the grace window is recovered by - `resumePendingDeletes()` (it re-fetches the channel by `discordThreadId` and deletes it). - - Button path previously set `discordThreadId: null` *before* the delete — that made the - channel unrecoverable on restart. Changed to `{ pendingDelete: true }`, leaving - `discordThreadId` set (matches the auto-close contract). -3. The grace delay is kept (staff read the close message first); recovery now covers it. - -## Files (DONE) -- `services/tickets.js` — added `scheduleTicketChannelDelete()`; auto-close else-branch now - calls it; exported. -- `handlers/buttons.js` `runFinalClose` — `attemptCloseTransition(..., { pendingDelete: true })` - (was `{ discordThreadId: null }`); delete via `scheduleTicketChannelDelete`. -- `handlers/commands/close.js` `finalizeForceClose` — same `{ pendingDelete: true }`; delete via - `scheduleTicketChannelDelete`. - -## Notes / residual -- Pre-existing orphans (e.g. #14) have `pendingDelete: false`, so `resumePendingDeletes` - will NOT auto-heal them — they need the one-off manual cleanup (same as #18). -- A non-restart `enqueueDelete` failure leaves `pendingDelete: true` until the next boot - (resume retries). Same property the auto-close path already had — accepted. -- Closed tickets now retain `discordThreadId` (like auto-close already did); nothing queries - closed tickets by channel, and the deleted channel id never re-matches a live channel. - -## Out of scope -- The in-memory countdown itself (a restart during the *countdown*, before finalize, - simply cancels the pending close — acceptable; staff can re-close). diff --git a/.scratch/folder-lifecycle/design.md b/.scratch/folder-lifecycle/design.md deleted file mode 100644 index 8d76891..0000000 --- a/.scratch/folder-lifecycle/design.md +++ /dev/null @@ -1,73 +0,0 @@ -# Folder lifecycle: Triage → Awaiting Reply → Needs Response - -## Goal -Auto-advance a ticket's Gmail folder as the conversation moves, so the inbox -reflects who owes a reply: -- New email ticket → **Triage** (unchanged). -- Staff reply reaches the customer → **Awaiting Reply**. -- Customer responds → **Needs Response**. -- Escalate → **Escalated** (unchanged; cycle resumes on the next reply/response). -- Close → **Resolved** (unchanged). - -Triage means "new, not yet replied to"; the first staff reply moves Triage→Awaiting Reply. - -## Folder taxonomy (new classification) -- **Auto-cycle** (bot moves these): `TRIAGE`, `ESCALATED`, `AWAITING_REPLY`, - `NEEDS_RESPONSE`, `RESOLVED`. -- **Manual / exempt** (bot never auto-moves): `FOR_JAKE`, `SPAM`, - `PARTNERSHIP_OFFERS`, `DASHBOARD_ERRORS`. - -A customer reply (or staff reply) to a thread sitting in a manual folder leaves it -there; only auto-cycle (or unlabeled) threads advance. - -## Mechanism -New `services/gmailLabels.js` exports: -- `MANUAL_KEYS` — the exempt set above. -- `getManagedFolderKey(threadId, gmail)` — reads the thread's existing labels - (union of message `labelIds`), maps to a managed folder key, returns it or null. - **Read-only**: never creates a label just to check (uses the existing name→id - cache; SPAM maps to the system `SPAM` id). -- `autoAdvanceFolder(threadId, targetKey, gmail)`: - ``` - current = getManagedFolderKey(threadId, gmail) - if current ∈ MANUAL_KEYS: return false // filed manually → don't touch - await moveThreadToFolder(threadId, targetKey, gmail) - return true - ``` - -## Decisions baked in (from brainstorming) -- "A response given" = a reply that actually emails the customer. Today that's the - typed-reply path (`handleDiscordReply` → `sendGmailReply`). `/response send` does - NOT email today, so it is intentionally NOT wired here; if it's ever wired to - email, it routes through the same send point and gets the move for free. -- Escalation keeps its own distinct `ESCALATED` folder; it is auto-cycle eligible, - so the next staff reply → Awaiting Reply and the next customer reply → Needs Response. -- Manual filings (For Jake / Spam / Partnership / Dashboard Errors) are never - overridden by the auto-flow. - -## Hooks -- `handlers/messages.js` — after a successful `sendGmailReply`, fire-and-forget - `autoAdvanceFolder(ticket.gmailThreadId, 'AWAITING_REPLY')`. -- `gmail-poll.js` (~line 308, the follow-up-to-existing-channel branch) — replace - the "leave folder untouched" behavior with fire-and-forget - `autoAdvanceFolder(parsed.threadId, 'NEEDS_RESPONSE', gmail)`. - -Both guard Discord-origin tickets (`gmailThreadId` starts `discord-`) and are -fire-and-forget (`.catch(() => {})`) so a label failure (e.g. stale Gmail auth) -never breaks the reply or the poll. - -## Files -- `services/gmailLabels.js` — 2 new `FOLDER_DEFS` entries; `MANUAL_KEYS`; - `getManagedFolderKey()`; `autoAdvanceFolder()`; add to exports. -- `config.js` — `GMAIL_LABEL_AWAITING_REPLY` (default `Awaiting Reply`), - `GMAIL_LABEL_NEEDS_RESPONSE` (default `Needs Response`). -- `.env.example` — document the two new vars. -- `handlers/messages.js` — Awaiting Reply hook. -- `gmail-poll.js` — Needs Response hook. -- `tests/gmailLabels.test.js` — cover manual-exemption gating + happy-path advance. - -## Out of scope -- Wiring `/response send` to email (separate concern; user is unsure `/response` - is even worth keeping). -- Reopen flow (a customer reply after the Discord channel is deleted creates a new - channel → Triage via the existing path; unchanged). diff --git a/.scratch/forward-command/design.md b/.scratch/forward-command/design.md deleted file mode 100644 index 80c0046..0000000 --- a/.scratch/forward-command/design.md +++ /dev/null @@ -1,54 +0,0 @@ -# `/forward` — forward a ticket's email thread to a third party - -## Goal -Let staff forward a ticket's entire email conversation to any email address from -inside the ticket channel. The original customer must **never** be looped in. - -## Command -``` -/forward email:
[note:] -``` -- `email` — required destination address; validated with `EMAIL_RE`, header-sanitized. -- `note` — optional cover message prepended above the transcript (max 1000 chars). -- Staff-only (the command dispatcher gates every command via `requireStaffRole`); - `ManageMessages` default member permission, matching other ticket-mod commands. - -## Customer-isolation guarantees (the hard requirement) -1. `To:` = target address **only**. No `Cc`, no `Bcc`. -2. The Gmail `messages.send` call carries **no `threadId`** → a brand-new thread, - not an append to the customer's conversation. -3. **No `In-Reply-To` / `References`** headers. -4. The customer's `senderEmail` is never written into any recipient header. -5. Fresh `Subject: Fwd: `. - -## Flow -- `handlers/commands/forward.js` → `handleForward(interaction)`: - 1. `findTicketForChannel` — else ephemeral "not a ticket channel". - 2. Discord-origin ticket (`gmailThreadId` starts `discord-`) → ephemeral "no email thread". - 3. `deferReply` ephemerally (fetch + attachment download can exceed 3s). - 4. `forwardThread(gmailThreadId, email, note, userId)`. - 5. Ephemeral confirmation: "Forwarded N messages (M attachments) to x@y.com" - (+ note if any attachments were skipped for size). `logTicketEvent`. -- `services/gmail.js` → new `forwardThread(threadId, targetEmail, note, userId)`: - 1. Validate target (`EMAIL_RE`); throw `EBADRECIPIENT` if bad. - 2. `threads.get(format:'full')`; throw `EEMPTY` if no messages. - 3. Subject from first message → `Fwd: ` (strip existing `Fwd:`), RFC2047-encode. - 4. Per message oldest→newest: header line (`From:`/`Date:`) + `getCleanBody`; build - parallel text and HTML blocks (HTML body `escapeHtml`-ed — customer content). - 5. Collect attachment parts (recursive walk for `filename` + `body.attachmentId`); - download via `messages.attachments.get`. **Cap total ~20 MB**; past it → skip + count. - 6. Compose a **new** outbound email: `From: support@`, `To: target only`, - `multipart/mixed` (attachments) wrapping `multipart/alternative` (text+html). - Plain-ASCII divider between messages in text; `
` in HTML. - 7. `messages.send` (no `threadId`). Return `{ messageCount, attachmentCount, skipped }`. - -## Files -- `commands/register.js` — `/forward` builder (after `folder`). -- `services/gmail.js` — `forwardThread()` + `collectAttachmentParts()` helper; export; - import `getCleanBody` from `utils`. -- `handlers/commands/forward.js` — new handler. -- `handlers/commands/index.js` — import + `COMMAND_HANDLERS.forward`; `/help` line. - -## Out of scope -- Forwarding a single message or letting staff pick one (whole-thread only). -- Native RFC822 re-send (we build a fresh email instead).