hotsheet 0.17.0-beta.16 → 0.17.0-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/channel.js CHANGED
@@ -34369,7 +34369,7 @@ async function proxyRequest(settings, path, init, fetchFn) {
34369
34369
  var UpdateTicketInputSchema = external_exports3.object({
34370
34370
  id: external_exports3.number().int().describe("Ticket id (numeric, e.g. 42)"),
34371
34371
  status: TicketStatusSchema.optional(),
34372
- notes: external_exports3.string().optional().describe("Replace the ticket's notes JSON. Pass the full notes array as a JSON string."),
34372
+ notes: external_exports3.string().optional().describe('Append a new note. Pass the markdown body as a plain string (NOT a JSON array \u2014 the server wraps the text in `{id, text, created_at}` automatically). HS-8427 \u2014 if you mistakenly pass a JSON-stringified note array like `[{"text":"..."}]`, the server unwraps it defensively so the body renders correctly, but plain text is the documented + preferred shape.'),
34373
34373
  priority: TicketPrioritySchema.optional(),
34374
34374
  category: external_exports3.string().optional().describe('Category id (e.g. "bug", "feature", "task", "issue")'),
34375
34375
  up_next: external_exports3.boolean().optional(),
package/dist/cli.js CHANGED
@@ -575,6 +575,7 @@ __export(connection_exports, {
575
575
  getDataDir: () => getDataDir,
576
576
  getDb: () => getDb,
577
577
  getDbForDir: () => getDbForDir,
578
+ isRecoverableOpenError: () => isRecoverableOpenError,
578
579
  readRecoveryMarker: () => readRecoveryMarker,
579
580
  runWithDataDir: () => runWithDataDir,
580
581
  setDataDir: () => setDataDir
@@ -583,6 +584,20 @@ import { AsyncLocalStorage } from "async_hooks";
583
584
  import { PGlite } from "@electric-sql/pglite";
584
585
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, rmSync as rmSync2, writeFileSync } from "fs";
585
586
  import { join as join3 } from "path";
587
+ function isRecoverableOpenError(err) {
588
+ if (err === null || err === void 0) return false;
589
+ let message;
590
+ if (err instanceof Error) message = err.message;
591
+ else if (typeof err === "string") message = err;
592
+ else if (typeof err === "number" || typeof err === "boolean") message = String(err);
593
+ else return false;
594
+ const errName = err instanceof Error ? err.name : "";
595
+ if (message.includes("Aborted")) return true;
596
+ if (message.includes("RuntimeError")) return true;
597
+ if (errName === "RuntimeError") return true;
598
+ if (message.includes("catalog is missing")) return true;
599
+ return false;
600
+ }
586
601
  function recoveryMarkerPath(dataDir) {
587
602
  return join3(dataDir, RECOVERY_MARKER_FILENAME);
588
603
  }
@@ -710,9 +725,7 @@ async function openAndCacheDb(dbPath) {
710
725
  async function recoverFromOpenFailure(dbPath, err) {
711
726
  const message = err instanceof Error ? err.message : String(err);
712
727
  const stack = err instanceof Error ? err.stack : void 0;
713
- const errName = err instanceof Error ? err.name : "";
714
- const isRuntimeFailure = message.includes("Aborted") || message.includes("RuntimeError") || errName === "RuntimeError";
715
- if (!isRuntimeFailure) throw err;
728
+ if (!isRecoverableOpenError(err)) throw err;
716
729
  console.error("Failed to open database:", message);
717
730
  if (stack !== void 0) console.error(stack);
718
731
  if (tryRemoveStalePostmasterPid(dbPath)) {
@@ -824,6 +837,12 @@ async function initSchema(db) {
824
837
  `).catch((e) => {
825
838
  if (e instanceof Error && !e.message.includes("already exists")) console.error("Migration error (columns):", e.message);
826
839
  });
840
+ await db.exec(`
841
+ ALTER TABLE attachments ADD COLUMN IF NOT EXISTS draft_id TEXT;
842
+ CREATE INDEX IF NOT EXISTS idx_attachments_draft ON attachments(draft_id);
843
+ `).catch((e) => {
844
+ if (e instanceof Error && !e.message.includes("already exists")) console.error("Migration error (attachments.draft_id):", e.message);
845
+ });
827
846
  await db.exec(`
828
847
  CREATE TABLE IF NOT EXISTS ticket_sync (
829
848
  id SERIAL PRIMARY KEY,
@@ -921,7 +940,7 @@ var SCHEMA_VERSION, RECOVERY_MARKER_FILENAME, databases, defaultDbPath, requestD
921
940
  var init_connection = __esm({
922
941
  "src/db/connection.ts"() {
923
942
  "use strict";
924
- SCHEMA_VERSION = 1;
943
+ SCHEMA_VERSION = 2;
925
944
  RECOVERY_MARKER_FILENAME = ".db-recovery-marker.json";
926
945
  databases = /* @__PURE__ */ new Map();
927
946
  defaultDbPath = null;
@@ -16022,6 +16041,18 @@ var init_backup = __esm({
16022
16041
  });
16023
16042
 
16024
16043
  // src/db/attachments.ts
16044
+ var attachments_exports = {};
16045
+ __export(attachments_exports, {
16046
+ addAttachment: () => addAttachment,
16047
+ addDraftAttachment: () => addDraftAttachment,
16048
+ deleteAttachment: () => deleteAttachment,
16049
+ deleteDraftAttachments: () => deleteDraftAttachments,
16050
+ getAttachment: () => getAttachment,
16051
+ getAttachments: () => getAttachments,
16052
+ getDraftAttachments: () => getDraftAttachments,
16053
+ listOrphanDraftAttachments: () => listOrphanDraftAttachments,
16054
+ promoteDraftAttachments: () => promoteDraftAttachments
16055
+ });
16025
16056
  async function addAttachment(ticketId, originalFilename, storedPath) {
16026
16057
  const db = await getDb();
16027
16058
  const result = await db.query(
@@ -16030,14 +16061,30 @@ async function addAttachment(ticketId, originalFilename, storedPath) {
16030
16061
  );
16031
16062
  return result.rows[0];
16032
16063
  }
16064
+ async function addDraftAttachment(ticketId, draftId, originalFilename, storedPath) {
16065
+ const db = await getDb();
16066
+ const result = await db.query(
16067
+ `INSERT INTO attachments (ticket_id, draft_id, original_filename, stored_path) VALUES ($1, $2, $3, $4) RETURNING *`,
16068
+ [ticketId, draftId, originalFilename, storedPath]
16069
+ );
16070
+ return result.rows[0];
16071
+ }
16033
16072
  async function getAttachments(ticketId) {
16034
16073
  const db = await getDb();
16035
16074
  const result = await db.query(
16036
- `SELECT * FROM attachments WHERE ticket_id = $1 ORDER BY created_at ASC`,
16075
+ `SELECT * FROM attachments WHERE ticket_id = $1 AND draft_id IS NULL ORDER BY created_at ASC`,
16037
16076
  [ticketId]
16038
16077
  );
16039
16078
  return result.rows;
16040
16079
  }
16080
+ async function getDraftAttachments(draftId) {
16081
+ const db = await getDb();
16082
+ const result = await db.query(
16083
+ `SELECT * FROM attachments WHERE draft_id = $1 ORDER BY created_at ASC`,
16084
+ [draftId]
16085
+ );
16086
+ return result.rows;
16087
+ }
16041
16088
  async function getAttachment(id) {
16042
16089
  const db = await getDb();
16043
16090
  const result = await db.query(
@@ -16054,6 +16101,36 @@ async function deleteAttachment(id) {
16054
16101
  );
16055
16102
  return result.rows[0] ?? null;
16056
16103
  }
16104
+ async function promoteDraftAttachments(draftId) {
16105
+ const db = await getDb();
16106
+ const result = await db.query(
16107
+ `UPDATE attachments SET draft_id = NULL WHERE draft_id = $1 RETURNING *`,
16108
+ [draftId]
16109
+ );
16110
+ return result.rows;
16111
+ }
16112
+ async function deleteDraftAttachments(draftId) {
16113
+ const db = await getDb();
16114
+ const result = await db.query(
16115
+ `DELETE FROM attachments WHERE draft_id = $1 RETURNING *`,
16116
+ [draftId]
16117
+ );
16118
+ return result.rows;
16119
+ }
16120
+ async function listOrphanDraftAttachments(olderThanMs) {
16121
+ const db = await getDb();
16122
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
16123
+ const result = await db.query(
16124
+ `SELECT a.* FROM attachments a
16125
+ LEFT JOIN feedback_drafts d ON d.id = a.draft_id
16126
+ WHERE a.draft_id IS NOT NULL
16127
+ AND d.id IS NULL
16128
+ AND a.created_at < $1
16129
+ ORDER BY a.created_at ASC`,
16130
+ [cutoff]
16131
+ );
16132
+ return result.rows;
16133
+ }
16057
16134
  var init_attachments = __esm({
16058
16135
  "src/db/attachments.ts"() {
16059
16136
  "use strict";
@@ -16176,6 +16253,31 @@ function parseNotes(raw) {
16176
16253
  }
16177
16254
  return [{ id: generateNoteId(), text: raw, created_at: (/* @__PURE__ */ new Date()).toISOString() }];
16178
16255
  }
16256
+ function normalizeNotesAppend(raw) {
16257
+ if (raw === "") return [];
16258
+ const trimmed = raw.trimStart();
16259
+ if (!trimmed.startsWith("[")) return [raw];
16260
+ let parsed;
16261
+ try {
16262
+ parsed = JSON.parse(raw);
16263
+ } catch {
16264
+ return [raw];
16265
+ }
16266
+ if (!Array.isArray(parsed)) return [raw];
16267
+ if (parsed.length === 0) return [raw];
16268
+ const ALLOWED_KEYS = /* @__PURE__ */ new Set(["text", "id", "created_at"]);
16269
+ const texts = [];
16270
+ for (const entry of parsed) {
16271
+ if (entry === null || typeof entry !== "object") return [raw];
16272
+ const obj = entry;
16273
+ if (typeof obj.text !== "string") return [raw];
16274
+ for (const key of Object.keys(obj)) {
16275
+ if (!ALLOWED_KEYS.has(key)) return [raw];
16276
+ }
16277
+ texts.push(obj.text);
16278
+ }
16279
+ return texts;
16280
+ }
16179
16281
  async function editNote(ticketId, noteId2, text) {
16180
16282
  const db = await getDb();
16181
16283
  const result = await db.query(`SELECT notes FROM tickets WHERE id = $1`, [ticketId]);
@@ -16485,12 +16587,18 @@ async function updateTicket(id, updates, options) {
16485
16587
  paramIdx++;
16486
16588
  }
16487
16589
  if (updates.notes !== void 0 && updates.notes !== "") {
16488
- const current = await db.query(`SELECT notes FROM tickets WHERE id = $1`, [id]);
16489
- const existing = parseNotes(current.rows[0]?.notes || "");
16490
- existing.push({ id: generateNoteId(), text: updates.notes, created_at: (/* @__PURE__ */ new Date()).toISOString() });
16491
- sets.push(`notes = $${paramIdx}`);
16492
- values.push(JSON.stringify(existing));
16493
- paramIdx++;
16590
+ const bodies = normalizeNotesAppend(updates.notes);
16591
+ if (bodies.length > 0) {
16592
+ const current = await db.query(`SELECT notes FROM tickets WHERE id = $1`, [id]);
16593
+ const existing = parseNotes(current.rows[0]?.notes || "");
16594
+ const now = (/* @__PURE__ */ new Date()).toISOString();
16595
+ for (const body of bodies) {
16596
+ existing.push({ id: generateNoteId(), text: body, created_at: now });
16597
+ }
16598
+ sets.push(`notes = $${paramIdx}`);
16599
+ values.push(JSON.stringify(existing));
16600
+ paramIdx++;
16601
+ }
16494
16602
  }
16495
16603
  if (updates.status === "completed") {
16496
16604
  sets.push("completed_at = NOW()");
@@ -16871,6 +16979,7 @@ var init_tickets = __esm({
16871
16979
  var queries_exports = {};
16872
16980
  __export(queries_exports, {
16873
16981
  addAttachment: () => addAttachment,
16982
+ addDraftAttachment: () => addDraftAttachment,
16874
16983
  addLogEntry: () => addLogEntry,
16875
16984
  batchDeleteTickets: () => batchDeleteTickets,
16876
16985
  batchRestoreTickets: () => batchRestoreTickets,
@@ -16879,6 +16988,7 @@ __export(queries_exports, {
16879
16988
  countSearchMatchesInExcludedStatuses: () => countSearchMatchesInExcludedStatuses,
16880
16989
  createTicket: () => createTicket,
16881
16990
  deleteAttachment: () => deleteAttachment,
16991
+ deleteDraftAttachments: () => deleteDraftAttachments,
16882
16992
  deleteNote: () => deleteNote,
16883
16993
  deleteTicket: () => deleteTicket,
16884
16994
  duplicateTickets: () => duplicateTickets,
@@ -16890,6 +17000,7 @@ __export(queries_exports, {
16890
17000
  getAttachment: () => getAttachment,
16891
17001
  getAttachments: () => getAttachments,
16892
17002
  getCategories: () => getCategories,
17003
+ getDraftAttachments: () => getDraftAttachments,
16893
17004
  getLogCount: () => getLogCount,
16894
17005
  getLogEntries: () => getLogEntries,
16895
17006
  getSettings: () => getSettings,
@@ -16901,8 +17012,11 @@ __export(queries_exports, {
16901
17012
  hardDeleteTicket: () => hardDeleteTicket,
16902
17013
  isExactTicketIdSearch: () => isExactTicketIdSearch,
16903
17014
  listKnownTicketPrefixes: () => listKnownTicketPrefixes,
17015
+ listOrphanDraftAttachments: () => listOrphanDraftAttachments,
16904
17016
  nextTicketNumber: () => nextTicketNumber,
17017
+ normalizeNotesAppend: () => normalizeNotesAppend,
16905
17018
  parseNotes: () => parseNotes,
17019
+ promoteDraftAttachments: () => promoteDraftAttachments,
16906
17020
  pruneLog: () => pruneLog,
16907
17021
  queryTickets: () => queryTickets,
16908
17022
  restoreTicket: () => restoreTicket,
@@ -24274,13 +24388,13 @@ import { pathToFileURL as pathToFileURL2 } from "url";
24274
24388
  // src/cleanup.ts
24275
24389
  init_queries();
24276
24390
  import { rmSync as rmSync4 } from "fs";
24391
+ var ORPHAN_DRAFT_ATTACHMENT_HORIZON_MS = 7 * 24 * 60 * 60 * 1e3;
24277
24392
  async function cleanupAttachments() {
24278
24393
  try {
24279
24394
  const settings = await getSettings();
24280
24395
  const verifiedDays = parseInt(settings.verified_cleanup_days, 10) || 30;
24281
24396
  const trashDays = parseInt(settings.trash_cleanup_days, 10) || 3;
24282
24397
  const tickets = await getTicketsForCleanup(verifiedDays, trashDays);
24283
- if (tickets.length === 0) return;
24284
24398
  let archived = 0;
24285
24399
  let deleted = 0;
24286
24400
  for (const ticket of tickets) {
@@ -24299,10 +24413,21 @@ async function cleanupAttachments() {
24299
24413
  deleted++;
24300
24414
  }
24301
24415
  }
24302
- if (archived > 0 || deleted > 0) {
24416
+ let orphans = 0;
24417
+ const orphanList = await listOrphanDraftAttachments(ORPHAN_DRAFT_ATTACHMENT_HORIZON_MS);
24418
+ for (const att of orphanList) {
24419
+ try {
24420
+ rmSync4(att.stored_path, { force: true });
24421
+ } catch {
24422
+ }
24423
+ await deleteAttachment(att.id);
24424
+ orphans++;
24425
+ }
24426
+ if (archived > 0 || deleted > 0 || orphans > 0) {
24303
24427
  const parts = [];
24304
24428
  if (archived > 0) parts.push(`archived ${archived} verified ticket(s)`);
24305
24429
  if (deleted > 0) parts.push(`deleted ${deleted} trashed ticket(s)`);
24430
+ if (orphans > 0) parts.push(`GC'd ${orphans} orphan draft attachment(s)`);
24306
24431
  console.log(` Cleanup: ${parts.join(", ")}.`);
24307
24432
  }
24308
24433
  } catch (err) {
@@ -24668,6 +24793,43 @@ attachmentRoutes.post("/tickets/:id/attachments", async (c) => {
24668
24793
  notifyMutation(c.get("dataDir"));
24669
24794
  return c.json(attachment, 201);
24670
24795
  });
24796
+ attachmentRoutes.post("/tickets/:id/feedback-drafts/:draftId/attachments", async (c) => {
24797
+ const id = parseIntParam(c, "id");
24798
+ if (id === null) return c.json({ error: "Invalid attachment ticket ID" }, 400);
24799
+ const draftId = c.req.param("draftId");
24800
+ if (draftId === "") return c.json({ error: "Invalid draft ID" }, 400);
24801
+ const ticket = await getTicket(id);
24802
+ if (!ticket) return c.json({ error: "Ticket not found" }, 404);
24803
+ const dataDir = c.get("dataDir");
24804
+ const body = await c.req.parseBody();
24805
+ const file2 = body["file"];
24806
+ if (typeof file2 === "string") return c.json({ error: "No file uploaded" }, 400);
24807
+ const originalName = file2.name;
24808
+ const ext = extname(originalName);
24809
+ const baseName = basename3(originalName, ext);
24810
+ const storedName = `${ticket.ticket_number}_draft_${draftId}_${baseName}${ext}`;
24811
+ const attachDir = join22(dataDir, "attachments");
24812
+ mkdirSync10(attachDir, { recursive: true });
24813
+ const storedPath = join22(attachDir, storedName);
24814
+ const buffer = Buffer.from(await file2.arrayBuffer());
24815
+ const { writeFileSync: writeFileSync17 } = await import("fs");
24816
+ writeFileSync17(storedPath, buffer);
24817
+ const attachment = await addDraftAttachment(id, draftId, originalName, storedPath);
24818
+ notifyMutation(dataDir);
24819
+ return c.json(attachment, 201);
24820
+ });
24821
+ attachmentRoutes.post("/tickets/:id/feedback-drafts/:draftId/promote-attachments", async (c) => {
24822
+ const id = parseIntParam(c, "id");
24823
+ if (id === null) return c.json({ error: "Invalid ticket ID" }, 400);
24824
+ const draftId = c.req.param("draftId");
24825
+ if (draftId === "") return c.json({ error: "Invalid draft ID" }, 400);
24826
+ const ticket = await getTicket(id);
24827
+ if (!ticket) return c.json({ error: "Ticket not found" }, 404);
24828
+ const { promoteDraftAttachments: promoteDraftAttachments2 } = await Promise.resolve().then(() => (init_attachments(), attachments_exports));
24829
+ const promoted = await promoteDraftAttachments2(draftId);
24830
+ notifyMutation(c.get("dataDir"));
24831
+ return c.json({ promoted: promoted.length, attachments: promoted });
24832
+ });
24671
24833
  attachmentRoutes.delete("/attachments/:id", async (c) => {
24672
24834
  const id = parseIntParam(c, "id");
24673
24835
  if (id === null) return c.json({ error: "Invalid attachment ID" }, 400);
@@ -25238,7 +25400,7 @@ function coerceFreezeEntry(raw) {
25238
25400
  if (raw === null || typeof raw !== "object") return null;
25239
25401
  const r = raw;
25240
25402
  const source = r.source;
25241
- if (source !== "client-observer" && source !== "client-heartbeat") return null;
25403
+ if (source !== "client-observer" && source !== "client-heartbeat" && source !== "client-server-busy-banner") return null;
25242
25404
  const durationMs = r.durationMs;
25243
25405
  if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs < 0) return null;
25244
25406
  const context = typeof r.context === "string" ? r.context : "";
@@ -25562,7 +25724,12 @@ ticketRoutes.get("/tickets/:id/feedback-drafts", async (c) => {
25562
25724
  if (id === null) return c.json({ error: "Invalid ticket ID" }, 400);
25563
25725
  const { listFeedbackDrafts: listFeedbackDrafts2 } = await Promise.resolve().then(() => (init_feedbackDrafts(), feedbackDrafts_exports));
25564
25726
  const drafts = await listFeedbackDrafts2(id);
25565
- return c.json(drafts);
25727
+ const { getDraftAttachments: getDraftAttachments2 } = await Promise.resolve().then(() => (init_attachments(), attachments_exports));
25728
+ const hydrated = await Promise.all(drafts.map(async (d) => ({
25729
+ ...d,
25730
+ attachments: await getDraftAttachments2(d.id)
25731
+ })));
25732
+ return c.json(hydrated);
25566
25733
  });
25567
25734
  ticketRoutes.post("/tickets/:id/feedback-drafts", async (c) => {
25568
25735
  const id = parseIntParam(c);
@@ -25599,10 +25766,18 @@ ticketRoutes.delete("/tickets/:id/feedback-drafts/:draftId", async (c) => {
25599
25766
  if (id === null) return c.json({ error: "Invalid ticket ID" }, 400);
25600
25767
  const draftId = c.req.param("draftId");
25601
25768
  const { deleteFeedbackDraft: deleteFeedbackDraft2 } = await Promise.resolve().then(() => (init_feedbackDrafts(), feedbackDrafts_exports));
25769
+ const { deleteDraftAttachments: deleteDraftAttachments2 } = await Promise.resolve().then(() => (init_attachments(), attachments_exports));
25770
+ const droppedAttachments = await deleteDraftAttachments2(draftId);
25771
+ for (const att of droppedAttachments) {
25772
+ try {
25773
+ rmSync10(att.stored_path, { force: true });
25774
+ } catch {
25775
+ }
25776
+ }
25602
25777
  const deleted = await deleteFeedbackDraft2(draftId);
25603
- if (!deleted) return c.json({ error: "Not found" }, 404);
25778
+ if (!deleted && droppedAttachments.length === 0) return c.json({ error: "Not found" }, 404);
25604
25779
  notifyMutation(c.get("dataDir"));
25605
- return c.json({ ok: true });
25780
+ return c.json({ ok: true, droppedAttachments: droppedAttachments.length });
25606
25781
  });
25607
25782
  ticketRoutes.delete("/tickets/:id/hard", async (c) => {
25608
25783
  const id = parseIntParam(c);