Stop tracking .scratch/ (local planning scratchpad)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -48,5 +48,8 @@ cursor.yml
|
||||
|
||||
.claude/
|
||||
|
||||
# Local planning/issue-tracker scratchpad — specs & PRDs stay on disk, not in git
|
||||
.scratch/
|
||||
|
||||
CLAUDE.md
|
||||
*.bak*
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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:<address> [note:<text>]
|
||||
```
|
||||
- `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: <original subject>`.
|
||||
|
||||
## 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: <subject>` (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; `<hr>` 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).
|
||||
Reference in New Issue
Block a user