@nuasite/notes 0.22.2 → 0.22.4

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.
@@ -8,7 +8,9 @@
8
8
  */
9
9
 
10
10
  export type NoteType = 'comment' | 'suggestion'
11
- export type NoteStatus = 'open' | 'resolved' | 'applied' | 'rejected' | 'stale'
11
+ export type NoteStatus = 'open' | 'resolved' | 'applied' | 'rejected' | 'stale' | 'deleted'
12
+ export type NoteRole = 'agency' | 'client'
13
+ export type NoteHistoryAction = 'created' | 'updated' | 'resolved' | 'reopened' | 'applied' | 'deleted' | 'purged' | 'stale'
12
14
 
13
15
  export interface NoteRange {
14
16
  anchorText: string
@@ -24,6 +26,13 @@ export interface NoteReply {
24
26
  createdAt: string
25
27
  }
26
28
 
29
+ export interface NoteHistoryEntry {
30
+ at: string
31
+ action: NoteHistoryAction
32
+ role?: NoteRole
33
+ note?: string
34
+ }
35
+
27
36
  export interface NoteItem {
28
37
  id: string
29
38
  type: NoteType
@@ -38,6 +47,7 @@ export interface NoteItem {
38
47
  updatedAt?: string
39
48
  status: NoteStatus
40
49
  replies: NoteReply[]
50
+ history: NoteHistoryEntry[]
41
51
  }
42
52
 
43
53
  export interface NotesPageFile {
@@ -16,7 +16,7 @@ import fs from 'node:fs/promises'
16
16
  import path from 'node:path'
17
17
  import { generateNoteId } from './id-gen'
18
18
  import { normalizePagePath, pageToSlug } from './slug'
19
- import type { NoteItem, NoteItemPatch, NotesPageFile } from './types'
19
+ import type { NoteHistoryAction, NoteHistoryEntry, NoteItem, NoteItemPatch, NoteRole, NotesPageFile } from './types'
20
20
 
21
21
  const FILE_VERSION_HEADER = '// @nuasite/notes v1\n'
22
22
 
@@ -77,11 +77,20 @@ export class NotesJsonStore {
77
77
  // Tolerate the optional version header
78
78
  const stripped = raw.startsWith('//') ? raw.slice(raw.indexOf('\n') + 1) : raw
79
79
  const parsed = JSON.parse(stripped) as NotesPageFile
80
- // Normalize legacy / partial files
80
+ // Normalize legacy / partial files: items predating the history
81
+ // field get an empty array, items predating replies get an empty
82
+ // array. Everything else passes through untouched.
83
+ const items = Array.isArray(parsed.items)
84
+ ? parsed.items.map((it) => ({
85
+ ...it,
86
+ replies: Array.isArray(it.replies) ? it.replies : [],
87
+ history: Array.isArray((it as Partial<NoteItem>).history) ? (it as NoteItem).history : [],
88
+ }))
89
+ : []
81
90
  return {
82
91
  page: parsed.page ?? normalized,
83
92
  lastUpdated: parsed.lastUpdated ?? nowIso(),
84
- items: Array.isArray(parsed.items) ? parsed.items : [],
93
+ items,
85
94
  }
86
95
  } catch (err) {
87
96
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
@@ -104,16 +113,19 @@ export class NotesJsonStore {
104
113
  /** Add a new item. Generates the id, createdAt, and defaults status to "open". */
105
114
  async addItem(
106
115
  page: string,
107
- input: Omit<NoteItem, 'id' | 'createdAt' | 'status' | 'replies'> & {
116
+ input: Omit<NoteItem, 'id' | 'createdAt' | 'status' | 'replies' | 'history'> & {
108
117
  id?: string
109
118
  createdAt?: string
110
119
  status?: NoteItem['status']
111
120
  replies?: NoteItem['replies']
121
+ history?: NoteItem['history']
112
122
  },
123
+ role: NoteRole = 'client',
113
124
  ): Promise<NoteItem> {
114
125
  const normalized = normalizePagePath(page)
115
126
  return withLock(normalized, async () => {
116
127
  const file = await this.readPage(normalized)
128
+ const now = input.createdAt ?? nowIso()
117
129
  const item: NoteItem = {
118
130
  id: input.id ?? generateNoteId(),
119
131
  type: input.type,
@@ -124,9 +136,10 @@ export class NotesJsonStore {
124
136
  range: input.range ?? null,
125
137
  body: input.body,
126
138
  author: input.author,
127
- createdAt: input.createdAt ?? nowIso(),
139
+ createdAt: now,
128
140
  status: input.status ?? 'open',
129
141
  replies: input.replies ?? [],
142
+ history: input.history ?? [{ at: now, action: 'created', role }],
130
143
  }
131
144
  file.items.push(item)
132
145
  file.page = normalized
@@ -136,34 +149,65 @@ export class NotesJsonStore {
136
149
  })
137
150
  }
138
151
 
139
- /** Patch an existing item. Returns the updated item, or null if not found. */
140
- async updateItem(page: string, id: string, patch: NoteItemPatch): Promise<NoteItem | null> {
152
+ /**
153
+ * Patch an existing item. Returns the updated item, or null if not found.
154
+ *
155
+ * `historyAction` is appended to the item's audit trail. Default is
156
+ * `'updated'`; status-changing helpers (`setStatus`) supply more specific
157
+ * actions like `'resolved'` or `'applied'`.
158
+ */
159
+ async updateItem(
160
+ page: string,
161
+ id: string,
162
+ patch: NoteItemPatch,
163
+ role: NoteRole = 'client',
164
+ historyAction: NoteHistoryAction = 'updated',
165
+ historyNote?: string,
166
+ ): Promise<NoteItem | null> {
141
167
  const normalized = normalizePagePath(page)
142
168
  return withLock(normalized, async () => {
143
169
  const file = await this.readPage(normalized)
144
- const idx = file.items.findIndex(it => it.id === id)
170
+ const idx = file.items.findIndex((it) => it.id === id)
145
171
  if (idx === -1) return null
146
172
  const existing = file.items[idx]!
173
+ const now = nowIso()
174
+ const entry: NoteHistoryEntry = { at: now, action: historyAction, role }
175
+ if (historyNote) entry.note = historyNote
147
176
  const updated: NoteItem = {
148
177
  ...existing,
149
178
  ...patch,
150
179
  // `range: null` is a meaningful patch (clearing a range), preserve it
151
180
  range: 'range' in patch ? (patch.range ?? null) : existing.range,
152
- updatedAt: nowIso(),
181
+ updatedAt: now,
182
+ history: [...existing.history, entry],
153
183
  }
154
184
  file.items[idx] = updated
155
- file.lastUpdated = nowIso()
185
+ file.lastUpdated = now
156
186
  await this.writePageFile(normalized, file)
157
187
  return updated
158
188
  })
159
189
  }
160
190
 
161
- /** Delete an item by id. Returns true if it existed. */
162
- async deleteItem(page: string, id: string): Promise<boolean> {
191
+ /**
192
+ * Soft-delete an item flip its status to `'deleted'` and append a
193
+ * history entry. The item stays in the JSON file so the agency can
194
+ * always see what happened. Returns the updated item, or null if not
195
+ * found. Use `purgeItem` to hard-remove.
196
+ */
197
+ async deleteItem(page: string, id: string, role: NoteRole = 'client'): Promise<NoteItem | null> {
198
+ return this.updateItem(page, id, { status: 'deleted' }, role, 'deleted')
199
+ }
200
+
201
+ /**
202
+ * Hard-remove an item from the file. The agency uses this on items that
203
+ * have already been soft-deleted (or that they explicitly want gone).
204
+ * Returns true if the item existed.
205
+ */
206
+ async purgeItem(page: string, id: string): Promise<boolean> {
163
207
  const normalized = normalizePagePath(page)
164
208
  return withLock(normalized, async () => {
165
209
  const file = await this.readPage(normalized)
166
- const idx = file.items.findIndex(it => it.id === id)
210
+ const idx = file.items.findIndex((it) => it.id === id)
167
211
  if (idx === -1) return false
168
212
  file.items.splice(idx, 1)
169
213
  file.lastUpdated = nowIso()
@@ -10,14 +10,56 @@
10
10
  */
11
11
 
12
12
  /**
13
- * Item lifecycle. Notes start as `open`. Reviewers (or the agency) move them
14
- * through the other states. `applied` is reserved for suggestions that have
15
- * been written back to the source file via the apply flow (Phase 4).
13
+ * Item lifecycle. Notes start as `open`. The agency moves them through the
14
+ * other states.
15
+ *
16
+ * - `open` — created, not yet acted on
17
+ * - `resolved` — agency marked it handled (no source change)
18
+ * - `applied` — suggestion was written back to the source file
19
+ * - `rejected` — agency declined the suggestion
20
+ * - `stale` — anchor text drifted; the suggestion can no longer be applied
21
+ * - `deleted` — soft-deleted by the agency. Items in this state stay on
22
+ * disk so the audit trail is preserved; the client UI hides
23
+ * them and the agency UI shows them in a collapsed section.
24
+ * A subsequent `purge` call hard-removes the file entry.
16
25
  */
17
- export type NoteStatus = 'open' | 'resolved' | 'applied' | 'rejected' | 'stale'
26
+ export type NoteStatus = 'open' | 'resolved' | 'applied' | 'rejected' | 'stale' | 'deleted'
18
27
 
19
28
  export type NoteType = 'comment' | 'suggestion'
20
29
 
30
+ /**
31
+ * Permission roles. Drives both UI affordances and server-side gating:
32
+ *
33
+ * - `client` — the reviewer (default). Can create comments and suggestions
34
+ * and nothing else. Cannot resolve, apply, delete, or purge.
35
+ * Cannot see deleted items.
36
+ * - `agency` — the agency owner. Full controls. The role is identified by
37
+ * the `?nua-agency` URL flag (which sets a sticky cookie) and
38
+ * a matching `x-nua-role: agency` header on every API call.
39
+ *
40
+ * v0.2 ships role enforcement on a per-instance trust basis: the role flag
41
+ * is unauthenticated and anyone who knows the URL can become "agency".
42
+ * That's intentional for v0.2 — the surface is dev-only and the threat model
43
+ * is "stop a non-technical client from accidentally clicking Apply", not
44
+ * "harden against an adversary". A real auth handshake can come later.
45
+ */
46
+ export type NoteRole = 'agency' | 'client'
47
+
48
+ /**
49
+ * One entry in an item's audit trail. Every mutation appends one of these
50
+ * to `item.history` so the agency can always see what happened to an item,
51
+ * even after a soft delete.
52
+ */
53
+ export type NoteHistoryAction = 'created' | 'updated' | 'resolved' | 'reopened' | 'applied' | 'deleted' | 'purged' | 'stale'
54
+
55
+ export interface NoteHistoryEntry {
56
+ at: string
57
+ action: NoteHistoryAction
58
+ role?: NoteRole
59
+ /** Optional one-line note for context. Currently used by 'applied' to record file path. */
60
+ note?: string
61
+ }
62
+
21
63
  /**
22
64
  * Range payload for `type: "suggestion"` items.
23
65
  *
@@ -73,6 +115,14 @@ export interface NoteItem {
73
115
 
74
116
  status: NoteStatus
75
117
  replies: NoteReply[]
118
+
119
+ /**
120
+ * Audit trail of every mutation that has touched this item, in
121
+ * chronological order. Always contains at least one entry (`created`).
122
+ * Items predating this field get an empty array on read; the create
123
+ * timestamp is preserved separately on `createdAt`.
124
+ */
125
+ history: NoteHistoryEntry[]
76
126
  }
77
127
 
78
128
  /**
package/src/types.ts CHANGED
@@ -39,6 +39,17 @@ export interface NuaNotesOptions {
39
39
  */
40
40
  urlFlag?: string
41
41
 
42
+ /**
43
+ * URL query flag that activates agency mode. When set on a URL it grants
44
+ * agency permissions (Apply, Resolve, Delete, Purge) and persists a sticky
45
+ * cookie so subsequent navigation stays in agency mode without re-typing.
46
+ * Without this flag — and without the cookie — the overlay runs in client
47
+ * mode and the destructive actions are hidden.
48
+ *
49
+ * Default: `nua-agency`
50
+ */
51
+ agencyFlag?: string
52
+
42
53
  /**
43
54
  * Forward `/_nua/notes/*` requests through this proxy target. Mirrors the
44
55
  * pattern used by `@nuasite/cms` for sandbox/hosted dev. The target backend