hazo_ihelp 2.0.6 → 2.1.0

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.
Files changed (83) hide show
  1. package/README.md +16 -3
  2. package/SETUP_CHECKLIST.md +131 -0
  3. package/config/hazo_ihelp_config.ini.sample +41 -8
  4. package/db_setup_postgres.sql +52 -0
  5. package/db_setup_sqlite.sql +25 -0
  6. package/dist/api/create_config_handler.d.ts +2 -0
  7. package/dist/api/create_config_handler.d.ts.map +1 -1
  8. package/dist/api/create_config_handler.js +117 -81
  9. package/dist/api/create_config_handler.js.map +1 -1
  10. package/dist/api/create_ihelp_handler.d.ts +7 -14
  11. package/dist/api/create_ihelp_handler.d.ts.map +1 -1
  12. package/dist/api/create_ihelp_handler.js +277 -257
  13. package/dist/api/create_ihelp_handler.js.map +1 -1
  14. package/dist/api/image_upload_handler.d.ts.map +1 -1
  15. package/dist/api/image_upload_handler.js +114 -80
  16. package/dist/api/image_upload_handler.js.map +1 -1
  17. package/dist/components/editor/editor_collapsible_section.js +1 -1
  18. package/dist/components/editor/editor_collapsible_section.js.map +1 -1
  19. package/dist/components/editor/editor_form.js +1 -1
  20. package/dist/components/editor/editor_form.js.map +1 -1
  21. package/dist/components/editor/editor_topic_list_item.js +1 -1
  22. package/dist/components/editor/editor_topic_list_item.js.map +1 -1
  23. package/dist/components/hover_help_icon.d.ts +39 -0
  24. package/dist/components/hover_help_icon.d.ts.map +1 -0
  25. package/dist/components/hover_help_icon.js +29 -0
  26. package/dist/components/hover_help_icon.js.map +1 -0
  27. package/dist/components/ihelp_detail_panel.js +2 -2
  28. package/dist/components/ihelp_detail_panel.js.map +1 -1
  29. package/dist/components/ihelp_editor.js +1 -1
  30. package/dist/components/ihelp_editor.js.map +1 -1
  31. package/dist/components/ihelp_icon.js +1 -1
  32. package/dist/components/ihelp_icon.js.map +1 -1
  33. package/dist/components/ihelp_provider.d.ts.map +1 -1
  34. package/dist/components/ihelp_provider.js +6 -1
  35. package/dist/components/ihelp_provider.js.map +1 -1
  36. package/dist/components/ihelp_rich_editor.js +2 -2
  37. package/dist/components/ihelp_rich_editor.js.map +1 -1
  38. package/dist/components/ihelp_search_dialog.d.ts.map +1 -1
  39. package/dist/components/ihelp_search_dialog.js +5 -3
  40. package/dist/components/ihelp_search_dialog.js.map +1 -1
  41. package/dist/components/index.d.ts +1 -0
  42. package/dist/components/index.d.ts.map +1 -1
  43. package/dist/components/index.js +1 -0
  44. package/dist/components/index.js.map +1 -1
  45. package/dist/components/ui/badge.js +1 -1
  46. package/dist/components/ui/badge.js.map +1 -1
  47. package/dist/components/ui/button.js +1 -1
  48. package/dist/components/ui/button.js.map +1 -1
  49. package/dist/components/ui/dialog.js +1 -1
  50. package/dist/components/ui/dialog.js.map +1 -1
  51. package/dist/components/ui/input.js +1 -1
  52. package/dist/components/ui/input.js.map +1 -1
  53. package/dist/components/ui/label.js +1 -1
  54. package/dist/components/ui/label.js.map +1 -1
  55. package/dist/components/ui/popover.js +1 -1
  56. package/dist/components/ui/popover.js.map +1 -1
  57. package/dist/components/ui/select.js +1 -1
  58. package/dist/components/ui/select.js.map +1 -1
  59. package/dist/components/ui/switch.js +1 -1
  60. package/dist/components/ui/switch.js.map +1 -1
  61. package/dist/components/ui/textarea.js +1 -1
  62. package/dist/components/ui/textarea.js.map +1 -1
  63. package/dist/index.client.d.ts +2 -2
  64. package/dist/index.client.d.ts.map +1 -1
  65. package/dist/index.client.js +1 -1
  66. package/dist/index.client.js.map +1 -1
  67. package/dist/lib/config/hazo_ihelp_config.d.ts +106 -0
  68. package/dist/lib/config/hazo_ihelp_config.d.ts.map +1 -0
  69. package/dist/lib/config/hazo_ihelp_config.js +66 -0
  70. package/dist/lib/config/hazo_ihelp_config.js.map +1 -0
  71. package/dist/types/index.d.ts +45 -34
  72. package/dist/types/index.d.ts.map +1 -1
  73. package/dist/utils/logger.d.ts +25 -0
  74. package/dist/utils/logger.d.ts.map +1 -0
  75. package/dist/utils/logger.js +42 -0
  76. package/dist/utils/logger.js.map +1 -0
  77. package/package.json +74 -23
  78. package/config/hazo_ihelp_config.ini +0 -9
  79. package/dist/utils/cn.d.ts +0 -3
  80. package/dist/utils/cn.d.ts.map +0 -1
  81. package/dist/utils/cn.js +0 -6
  82. package/dist/utils/cn.js.map +0 -1
  83. package/migrations/001_create_ihelp_tables.sql +0 -58
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * iHelp API Handler Factory
3
3
  *
4
- * Creates GET, POST, PATCH, and DELETE handlers for ihelp topic endpoints.
5
- * These handlers should be used in a Next.js API route.
4
+ * Creates GET, POST, PATCH, and DELETE handlers for hazo_ihelp_topics.
5
+ * Tags and context mappings are stored as JSON columns directly on the
6
+ * topics table — no separate join tables required.
7
+ *
8
+ * Every handler wraps its body with hazo_core's `withContext` so the
9
+ * correlation ID flows through every nested log call and downstream
10
+ * `fetchWithRequestId` outbound request.
6
11
  *
7
12
  * @example
8
13
  * ```typescript
@@ -17,44 +22,50 @@
17
22
  * });
18
23
  *
19
24
  * export { GET, POST };
20
- *
21
- * // app/api/ihelp/topics/[id]/route.ts
22
- * import { createIhelpHandler } from 'hazo_ihelp/api';
23
- * import { getHazoConnectSingleton } from 'hazo_connect/nextjs/setup';
24
- *
25
- * export const dynamic = 'force-dynamic';
26
- *
27
- * const { PATCH, DELETE } = createIhelpHandler({
28
- * getHazoConnect: () => getHazoConnectSingleton(),
29
- * });
30
- *
31
- * export { PATCH, DELETE };
32
25
  * ```
33
26
  */
34
27
  import { NextResponse } from 'next/server';
35
28
  import { createCrudService } from 'hazo_connect/server';
29
+ import { HazoError, HazoValidationError, HazoNotFoundError, generateRequestId, getCorrelationId, withContext, } from 'hazo_core';
30
+ import { get_logger, set_logger } from '../utils/logger.js';
36
31
  // ============================================================================
37
32
  // Helper Functions
38
33
  // ============================================================================
39
- /** Generate a UUID v4 */
34
+ /** Generate a UUID for DB primary keys. Strips the req_ prefix from generateRequestId(). */
40
35
  function generateUUID() {
41
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
42
- const r = (Math.random() * 16) | 0;
43
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
44
- return v.toString(16);
45
- });
36
+ return generateRequestId().slice(4);
46
37
  }
47
- /** No-op logger */
48
- const noopLogger = {
49
- error: () => { },
50
- warn: () => { },
51
- info: () => { },
52
- debug: () => { },
53
- };
54
38
  /** Create a standardized error response */
55
39
  function createErrorResponse(error, status) {
56
40
  return NextResponse.json({ success: false, error }, { status });
57
41
  }
42
+ /** Wrap an error caught in a handler — log appropriately and return a response. */
43
+ function handleError(logger, event, error) {
44
+ if (HazoError.is(error)) {
45
+ const status = error.httpStatus ?? 500;
46
+ if (status >= 500) {
47
+ logger.error(event, { code: error.code, message: error.message, context: error.context });
48
+ }
49
+ else {
50
+ logger.warn(event, { code: error.code, message: error.message });
51
+ }
52
+ return createErrorResponse(error.message, status);
53
+ }
54
+ logger.error(event, { error: error instanceof Error ? error.message : String(error) });
55
+ return createErrorResponse('Internal server error', 500);
56
+ }
57
+ /** Parse a JSON column value that may be a string or already parsed */
58
+ function parseJsonColumn(value) {
59
+ if (typeof value === 'string') {
60
+ try {
61
+ return JSON.parse(value);
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ }
67
+ return (value ?? []);
68
+ }
58
69
  // ============================================================================
59
70
  // Handler Factory
60
71
  // ============================================================================
@@ -66,21 +77,22 @@ function createErrorResponse(error, status) {
66
77
  */
67
78
  export function createIhelpHandler(options) {
68
79
  const { getHazoConnect, getLogger } = options;
69
- const logger = getLogger?.() || noopLogger;
80
+ if (getLogger) {
81
+ // Consumer override — wire through the package-level logger.
82
+ set_logger(getLogger());
83
+ }
84
+ const logger = get_logger();
70
85
  // ---------------------------------------------------------------------------
71
- // Internal helper: assemble a full IhelpTopic with joined tags & context_keys
86
+ // Internal helper: normalise a DB row into a full IhelpTopic
72
87
  // ---------------------------------------------------------------------------
73
- async function assembleTopicWithJoins(topicRow, adapter) {
74
- const tagsService = createCrudService(adapter, 'ihelp_tags');
75
- const contextMapService = createCrudService(adapter, 'ihelp_context_map');
76
- const [tags, contextMappings] = await Promise.all([
77
- tagsService.findBy({ topic_id: topicRow.id }),
78
- contextMapService.findBy({ topic_id: topicRow.id }),
79
- ]);
88
+ function normaliseTopic(row) {
89
+ const tags = parseJsonColumn(row.tags);
90
+ const contexts = parseJsonColumn(row.contexts);
80
91
  return {
81
- ...topicRow,
92
+ ...row,
82
93
  tags,
83
- context_keys: contextMappings.map((m) => m.context_key),
94
+ contexts,
95
+ context_keys: contexts.map((c) => c.key),
84
96
  };
85
97
  }
86
98
  // ---------------------------------------------------------------------------
@@ -97,76 +109,81 @@ export function createIhelpHandler(options) {
97
109
  * - search — ilike filter on title and description
98
110
  */
99
111
  async function GET(request) {
100
- try {
101
- const searchParams = request.nextUrl.searchParams;
102
- const id = searchParams.get('id');
103
- const context_key = searchParams.get('context_key');
104
- const scope_id = searchParams.get('scope_id');
105
- const category = searchParams.get('category');
106
- const search = searchParams.get('search');
107
- logger.debug('[hazo_ihelp] GET request', { id, context_key, scope_id, category, search });
108
- const adapter = await getHazoConnect();
109
- const topicsService = createCrudService(adapter, 'ihelp_topics');
110
- // ---- Single topic fetch ----
111
- if (id) {
112
- const topic = await topicsService.findById(id);
113
- if (!topic) {
114
- return createErrorResponse('Topic not found', 404);
112
+ return withContext({ correlationId: getCorrelationId() ?? generateRequestId() }, async () => {
113
+ try {
114
+ const searchParams = request.nextUrl.searchParams;
115
+ const id = searchParams.get('id');
116
+ const context_key = searchParams.get('context_key');
117
+ const scope_id = searchParams.get('scope_id');
118
+ const category = searchParams.get('category');
119
+ const search = searchParams.get('search');
120
+ logger.debug('topic.list.request', { id, context_key, scope_id, category, search });
121
+ const adapter = await getHazoConnect();
122
+ const topicsService = createCrudService(adapter, 'hazo_ihelp_topics');
123
+ // ---- Single topic fetch ----
124
+ if (id) {
125
+ const topic = await topicsService.findById(id);
126
+ if (!topic) {
127
+ throw new HazoNotFoundError({
128
+ code: 'HAZO_IHELP_TOPIC_NOT_FOUND',
129
+ pkg: 'hazo_ihelp',
130
+ message: 'Topic not found',
131
+ context: { id },
132
+ });
133
+ }
134
+ return NextResponse.json({ success: true, data: normaliseTopic(topic) });
115
135
  }
116
- const assembled = await assembleTopicWithJoins(topic, adapter);
117
- return NextResponse.json({ success: true, data: assembled });
118
- }
119
- // ---- Context key lookup ----
120
- if (context_key) {
121
- const contextMapService = createCrudService(adapter, 'ihelp_context_map');
122
- const mappings = await contextMapService.findBy({ context_key });
123
- if (!mappings.length) {
124
- return NextResponse.json({ success: true, data: [] });
136
+ // ---- Context key lookup ----
137
+ if (context_key) {
138
+ // Fetch all topics, filter by context key in application code
139
+ // (works with both SQLite TEXT and PostgreSQL JSONB)
140
+ const rows = await topicsService.list((qb) => {
141
+ let q = qb;
142
+ if (scope_id) {
143
+ q = q.whereOr([
144
+ { field: 'scope_id', operator: 'is', value: null },
145
+ { field: 'scope_id', operator: 'eq', value: scope_id },
146
+ ]);
147
+ }
148
+ return q;
149
+ });
150
+ const matched = rows
151
+ .map(normaliseTopic)
152
+ .filter((t) => t.contexts.some((c) => c.key === context_key))
153
+ .sort((a, b) => {
154
+ const pa = a.contexts.find((c) => c.key === context_key)?.priority ?? 0;
155
+ const pb = b.contexts.find((c) => c.key === context_key)?.priority ?? 0;
156
+ return pa - pb;
157
+ });
158
+ return NextResponse.json({ success: true, data: matched });
125
159
  }
126
- // Sort by priority ascending, then fetch matching topics
127
- const sortedMappings = mappings.sort((a, b) => a.priority - b.priority);
128
- const topicIds = sortedMappings.map((m) => m.topic_id);
129
- const allTopics = [];
130
- for (const topicId of topicIds) {
131
- const topic = await topicsService.findById(topicId);
132
- if (topic) {
133
- const assembled = await assembleTopicWithJoins(topic, adapter);
134
- allTopics.push(assembled);
160
+ // ---- List with optional filters ----
161
+ const rows = await topicsService.list((qb) => {
162
+ let q = qb;
163
+ if (scope_id) {
164
+ q = q.whereOr([
165
+ { field: 'scope_id', operator: 'is', value: null },
166
+ { field: 'scope_id', operator: 'eq', value: scope_id },
167
+ ]);
135
168
  }
136
- }
137
- return NextResponse.json({ success: true, data: allTopics });
169
+ if (category) {
170
+ q = q.where('category', 'eq', category);
171
+ }
172
+ if (search) {
173
+ q = q.whereOr([
174
+ { field: 'title', operator: 'ilike', value: `%${search}%` },
175
+ { field: 'description', operator: 'ilike', value: `%${search}%` },
176
+ ]);
177
+ }
178
+ return q;
179
+ });
180
+ const assembled = rows.map(normaliseTopic);
181
+ return NextResponse.json({ success: true, data: assembled });
138
182
  }
139
- // ---- List with optional filters ----
140
- const rows = await topicsService.list((qb) => {
141
- let q = qb;
142
- if (scope_id) {
143
- // Topics where scope_id IS NULL or matches the given value
144
- q = q.whereOr([
145
- { field: 'scope_id', operator: 'is', value: null },
146
- { field: 'scope_id', operator: 'eq', value: scope_id },
147
- ]);
148
- }
149
- if (category) {
150
- q = q.where('category', 'eq', category);
151
- }
152
- if (search) {
153
- // ilike search on title and description
154
- q = q.whereOr([
155
- { field: 'title', operator: 'ilike', value: `%${search}%` },
156
- { field: 'description', operator: 'ilike', value: `%${search}%` },
157
- ]);
158
- }
159
- return q;
160
- });
161
- const assembled = await Promise.all(rows.map((row) => assembleTopicWithJoins(row, adapter)));
162
- return NextResponse.json({ success: true, data: assembled });
163
- }
164
- catch (error) {
165
- logger.error('[hazo_ihelp] GET error', {
166
- error: error instanceof Error ? error.message : String(error),
167
- });
168
- return createErrorResponse('Failed to fetch topics', 500);
169
- }
183
+ catch (error) {
184
+ return handleError(logger, 'topic.list.failed', error);
185
+ }
186
+ });
170
187
  }
171
188
  // ---------------------------------------------------------------------------
172
189
  // POST handler — create a new topic
@@ -177,64 +194,69 @@ export function createIhelpHandler(options) {
177
194
  * Body: IhelpTopicInput
178
195
  */
179
196
  async function POST(request) {
180
- try {
181
- const body = (await request.json());
182
- if (!body.title) {
183
- return createErrorResponse('title is required', 400);
184
- }
185
- logger.debug('[hazo_ihelp] POST request', { title: body.title });
186
- const adapter = await getHazoConnect();
187
- const topicsService = createCrudService(adapter, 'ihelp_topics');
188
- const tagsService = createCrudService(adapter, 'ihelp_tags');
189
- const contextMapService = createCrudService(adapter, 'ihelp_context_map');
190
- const topicId = generateUUID();
191
- const now = new Date().toISOString();
192
- // Insert topic
193
- const inserted = await topicsService.insert({
194
- id: topicId,
195
- parent_id: body.parent_id ?? null,
196
- scope_id: body.scope_id ?? null,
197
- category: body.category ?? '',
198
- title: body.title,
199
- description: body.description ?? null,
200
- content: body.content ?? null,
201
- icon: body.icon ?? null,
202
- sort_order: body.sort_order ?? 0,
203
- is_published: body.is_published ?? false,
204
- created_at: now,
205
- changed_at: null,
206
- });
207
- const topicRow = Array.isArray(inserted) ? inserted[0] : inserted;
208
- // Insert tags if provided
209
- if (body.tags && body.tags.length > 0) {
210
- const tagRows = body.tags.map((tag) => ({
211
- id: generateUUID(),
212
- topic_id: topicId,
213
- label: tag.label,
214
- variant: tag.variant ?? 'default',
215
- icon: tag.icon ?? null,
197
+ return withContext({ correlationId: getCorrelationId() ?? generateRequestId() }, async () => {
198
+ try {
199
+ const body = (await request.json());
200
+ if (!body.title) {
201
+ throw new HazoValidationError({
202
+ code: 'HAZO_IHELP_VALIDATION_ERROR',
203
+ pkg: 'hazo_ihelp',
204
+ message: 'title is required',
205
+ });
206
+ }
207
+ logger.debug('topic.create.request', { title: body.title });
208
+ const adapter = await getHazoConnect();
209
+ const topicsService = createCrudService(adapter, 'hazo_ihelp_topics');
210
+ const topicId = generateUUID();
211
+ const now = new Date().toISOString();
212
+ // Build tags array
213
+ const tags = (body.tags ?? []).map((t) => ({
214
+ label: t.label,
215
+ variant: t.variant ?? 'default',
216
+ icon: t.icon ?? null,
216
217
  }));
217
- await tagsService.insert(tagRows);
218
+ // Build contexts array — prefer explicit contexts, fall back to context_keys shorthand
219
+ let contexts;
220
+ if (body.contexts) {
221
+ contexts = body.contexts.map((c) => ({
222
+ key: c.key,
223
+ priority: c.priority ?? 0,
224
+ }));
225
+ }
226
+ else if (body.context_keys) {
227
+ contexts = body.context_keys.map((key, i) => ({
228
+ key,
229
+ priority: i,
230
+ }));
231
+ }
232
+ else {
233
+ contexts = [];
234
+ }
235
+ const inserted = await topicsService.insert({
236
+ id: topicId,
237
+ parent_id: body.parent_id ?? null,
238
+ scope_id: body.scope_id ?? null,
239
+ category: body.category ?? '',
240
+ title: body.title,
241
+ description: body.description ?? null,
242
+ content: body.content ?? null,
243
+ icon: body.icon ?? null,
244
+ sort_order: body.sort_order ?? 0,
245
+ is_published: body.is_published ?? false,
246
+ tags: JSON.stringify(tags),
247
+ contexts: JSON.stringify(contexts),
248
+ created_at: now,
249
+ changed_at: null,
250
+ });
251
+ const topicRow = Array.isArray(inserted) ? inserted[0] : inserted;
252
+ const topic = normaliseTopic(topicRow);
253
+ logger.info('topic.created', { topic_id: topic.id, scope_id: topic.scope_id ?? null });
254
+ return NextResponse.json({ success: true, data: topic }, { status: 201 });
218
255
  }
219
- // Insert context_keys if provided
220
- if (body.context_keys && body.context_keys.length > 0) {
221
- const mapRows = body.context_keys.map((key, index) => ({
222
- id: generateUUID(),
223
- topic_id: topicId,
224
- context_key: key,
225
- priority: index,
226
- }));
227
- await contextMapService.insert(mapRows);
256
+ catch (error) {
257
+ return handleError(logger, 'topic.create.failed', error);
228
258
  }
229
- const assembled = await assembleTopicWithJoins(topicRow, adapter);
230
- return NextResponse.json({ success: true, data: assembled }, { status: 201 });
231
- }
232
- catch (error) {
233
- logger.error('[hazo_ihelp] POST error', {
234
- error: error instanceof Error ? error.message : String(error),
235
- });
236
- return createErrorResponse('Failed to create topic', 500);
237
- }
259
+ });
238
260
  }
239
261
  // ---------------------------------------------------------------------------
240
262
  // PATCH handler — update an existing topic
@@ -246,93 +268,88 @@ export function createIhelpHandler(options) {
246
268
  * Body: Partial<IhelpTopicInput>
247
269
  */
248
270
  async function PATCH(request, context) {
249
- try {
250
- // Resolve id from params or query string
251
- const { id: paramId } = await context.params;
252
- let id = paramId;
253
- if (!id) {
254
- id = request.nextUrl.searchParams.get('id') ?? undefined;
255
- }
256
- if (!id) {
257
- return createErrorResponse('id is required', 400);
258
- }
259
- const body = (await request.json());
260
- logger.debug('[hazo_ihelp] PATCH request', { id });
261
- const adapter = await getHazoConnect();
262
- const topicsService = createCrudService(adapter, 'ihelp_topics');
263
- const tagsService = createCrudService(adapter, 'ihelp_tags');
264
- const contextMapService = createCrudService(adapter, 'ihelp_context_map');
265
- // Build the patch payload (exclude relation fields)
266
- const patch = { changed_at: new Date().toISOString() };
267
- if (body.parent_id !== undefined)
268
- patch.parent_id = body.parent_id;
269
- if (body.scope_id !== undefined)
270
- patch.scope_id = body.scope_id;
271
- if (body.category !== undefined)
272
- patch.category = body.category;
273
- if (body.title !== undefined)
274
- patch.title = body.title;
275
- if (body.description !== undefined)
276
- patch.description = body.description;
277
- if (body.content !== undefined)
278
- patch.content = body.content;
279
- if (body.icon !== undefined)
280
- patch.icon = body.icon;
281
- if (body.sort_order !== undefined)
282
- patch.sort_order = body.sort_order;
283
- if (body.is_published !== undefined)
284
- patch.is_published = body.is_published;
285
- const updated = await topicsService.updateById(id, patch);
286
- if (!updated || updated.length === 0) {
287
- return createErrorResponse('Topic not found', 404);
288
- }
289
- const topicRow = updated[0];
290
- // Replace tags if provided
291
- if (body.tags !== undefined) {
292
- // Delete existing tags one-by-one (deleteById targets the primary key)
293
- const existingTags = await tagsService.findBy({ topic_id: id });
294
- for (const tag of existingTags) {
295
- await tagsService.deleteById(tag.id);
271
+ return withContext({ correlationId: getCorrelationId() ?? generateRequestId() }, async () => {
272
+ try {
273
+ const { id: paramId } = await context.params;
274
+ let id = paramId;
275
+ if (!id) {
276
+ id = request.nextUrl.searchParams.get('id') ?? undefined;
277
+ }
278
+ if (!id) {
279
+ throw new HazoValidationError({
280
+ code: 'HAZO_IHELP_VALIDATION_ERROR',
281
+ pkg: 'hazo_ihelp',
282
+ message: 'id is required',
283
+ });
296
284
  }
297
- if (body.tags.length > 0) {
298
- const tagRows = body.tags.map((tag) => ({
299
- id: generateUUID(),
300
- topic_id: id,
301
- label: tag.label,
302
- variant: tag.variant ?? 'default',
303
- icon: tag.icon ?? null,
285
+ const body = (await request.json());
286
+ logger.debug('topic.update.request', { id });
287
+ const adapter = await getHazoConnect();
288
+ const topicsService = createCrudService(adapter, 'hazo_ihelp_topics');
289
+ // Build the patch payload
290
+ const patch = { changed_at: new Date().toISOString() };
291
+ if (body.parent_id !== undefined)
292
+ patch.parent_id = body.parent_id;
293
+ if (body.scope_id !== undefined)
294
+ patch.scope_id = body.scope_id;
295
+ if (body.category !== undefined)
296
+ patch.category = body.category;
297
+ if (body.title !== undefined)
298
+ patch.title = body.title;
299
+ if (body.description !== undefined)
300
+ patch.description = body.description;
301
+ if (body.content !== undefined)
302
+ patch.content = body.content;
303
+ if (body.icon !== undefined)
304
+ patch.icon = body.icon;
305
+ if (body.sort_order !== undefined)
306
+ patch.sort_order = body.sort_order;
307
+ if (body.is_published !== undefined)
308
+ patch.is_published = body.is_published;
309
+ // Replace tags if provided
310
+ if (body.tags !== undefined) {
311
+ const tags = body.tags.map((t) => ({
312
+ label: t.label,
313
+ variant: t.variant ?? 'default',
314
+ icon: t.icon ?? null,
304
315
  }));
305
- await tagsService.insert(tagRows);
316
+ patch.tags = JSON.stringify(tags);
306
317
  }
307
- }
308
- // Replace context_keys if provided
309
- if (body.context_keys !== undefined) {
310
- const existingMappings = await contextMapService.findBy({ topic_id: id });
311
- for (const mapping of existingMappings) {
312
- await contextMapService.deleteById(mapping.id);
318
+ // Replace contexts if provided
319
+ if (body.contexts !== undefined) {
320
+ const contexts = body.contexts.map((c) => ({
321
+ key: c.key,
322
+ priority: c.priority ?? 0,
323
+ }));
324
+ patch.contexts = JSON.stringify(contexts);
313
325
  }
314
- if (body.context_keys.length > 0) {
315
- const mapRows = body.context_keys.map((key, index) => ({
316
- id: generateUUID(),
317
- topic_id: id,
318
- context_key: key,
319
- priority: index,
326
+ else if (body.context_keys !== undefined) {
327
+ const contexts = body.context_keys.map((key, i) => ({
328
+ key,
329
+ priority: i,
320
330
  }));
321
- await contextMapService.insert(mapRows);
331
+ patch.contexts = JSON.stringify(contexts);
322
332
  }
333
+ const updated = await topicsService.updateById(id, patch);
334
+ if (!updated || updated.length === 0) {
335
+ throw new HazoNotFoundError({
336
+ code: 'HAZO_IHELP_TOPIC_NOT_FOUND',
337
+ pkg: 'hazo_ihelp',
338
+ message: 'Topic not found',
339
+ context: { id },
340
+ });
341
+ }
342
+ const topic = normaliseTopic(updated[0]);
343
+ logger.info('topic.updated', { topic_id: id });
344
+ return NextResponse.json({ success: true, data: topic });
323
345
  }
324
- const assembled = await assembleTopicWithJoins(topicRow, adapter);
325
- return NextResponse.json({ success: true, data: assembled });
326
- }
327
- catch (error) {
328
- logger.error('[hazo_ihelp] PATCH error', {
329
- error: error instanceof Error ? error.message : String(error),
330
- });
331
- return createErrorResponse('Failed to update topic', 500);
332
- }
346
+ catch (error) {
347
+ return handleError(logger, 'topic.update.failed', error);
348
+ }
349
+ });
333
350
  }
334
351
  // ---------------------------------------------------------------------------
335
- // DELETE handler — remove a topic (CASCADE handles tags & context_map)
352
+ // DELETE handler — remove a topic
336
353
  // ---------------------------------------------------------------------------
337
354
  /**
338
355
  * DELETE handler
@@ -340,28 +357,31 @@ export function createIhelpHandler(options) {
340
357
  * Expects `id` as a URL search param or route segment param.
341
358
  */
342
359
  async function DELETE(request, context) {
343
- try {
344
- // Resolve id from params or query string
345
- const { id: paramId } = await context.params;
346
- let id = paramId;
347
- if (!id) {
348
- id = request.nextUrl.searchParams.get('id') ?? undefined;
360
+ return withContext({ correlationId: getCorrelationId() ?? generateRequestId() }, async () => {
361
+ try {
362
+ const { id: paramId } = await context.params;
363
+ let id = paramId;
364
+ if (!id) {
365
+ id = request.nextUrl.searchParams.get('id') ?? undefined;
366
+ }
367
+ if (!id) {
368
+ throw new HazoValidationError({
369
+ code: 'HAZO_IHELP_VALIDATION_ERROR',
370
+ pkg: 'hazo_ihelp',
371
+ message: 'id is required',
372
+ });
373
+ }
374
+ logger.debug('topic.delete.request', { id });
375
+ const adapter = await getHazoConnect();
376
+ const topicsService = createCrudService(adapter, 'hazo_ihelp_topics');
377
+ await topicsService.deleteById(id);
378
+ logger.info('topic.deleted', { topic_id: id });
379
+ return NextResponse.json({ success: true, data: null });
349
380
  }
350
- if (!id) {
351
- return createErrorResponse('id is required', 400);
381
+ catch (error) {
382
+ return handleError(logger, 'topic.delete.failed', error);
352
383
  }
353
- logger.debug('[hazo_ihelp] DELETE request', { id });
354
- const adapter = await getHazoConnect();
355
- const topicsService = createCrudService(adapter, 'ihelp_topics');
356
- await topicsService.deleteById(id);
357
- return NextResponse.json({ success: true, data: null });
358
- }
359
- catch (error) {
360
- logger.error('[hazo_ihelp] DELETE error', {
361
- error: error instanceof Error ? error.message : String(error),
362
- });
363
- return createErrorResponse('Failed to delete topic', 500);
364
- }
384
+ });
365
385
  }
366
386
  return { GET, POST, PATCH, DELETE };
367
387
  }