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.
- package/README.md +16 -3
- package/SETUP_CHECKLIST.md +131 -0
- package/config/hazo_ihelp_config.ini.sample +41 -8
- package/db_setup_postgres.sql +52 -0
- package/db_setup_sqlite.sql +25 -0
- package/dist/api/create_config_handler.d.ts +2 -0
- package/dist/api/create_config_handler.d.ts.map +1 -1
- package/dist/api/create_config_handler.js +117 -81
- package/dist/api/create_config_handler.js.map +1 -1
- package/dist/api/create_ihelp_handler.d.ts +7 -14
- package/dist/api/create_ihelp_handler.d.ts.map +1 -1
- package/dist/api/create_ihelp_handler.js +277 -257
- package/dist/api/create_ihelp_handler.js.map +1 -1
- package/dist/api/image_upload_handler.d.ts.map +1 -1
- package/dist/api/image_upload_handler.js +114 -80
- package/dist/api/image_upload_handler.js.map +1 -1
- package/dist/components/editor/editor_collapsible_section.js +1 -1
- package/dist/components/editor/editor_collapsible_section.js.map +1 -1
- package/dist/components/editor/editor_form.js +1 -1
- package/dist/components/editor/editor_form.js.map +1 -1
- package/dist/components/editor/editor_topic_list_item.js +1 -1
- package/dist/components/editor/editor_topic_list_item.js.map +1 -1
- package/dist/components/hover_help_icon.d.ts +39 -0
- package/dist/components/hover_help_icon.d.ts.map +1 -0
- package/dist/components/hover_help_icon.js +29 -0
- package/dist/components/hover_help_icon.js.map +1 -0
- package/dist/components/ihelp_detail_panel.js +2 -2
- package/dist/components/ihelp_detail_panel.js.map +1 -1
- package/dist/components/ihelp_editor.js +1 -1
- package/dist/components/ihelp_editor.js.map +1 -1
- package/dist/components/ihelp_icon.js +1 -1
- package/dist/components/ihelp_icon.js.map +1 -1
- package/dist/components/ihelp_provider.d.ts.map +1 -1
- package/dist/components/ihelp_provider.js +6 -1
- package/dist/components/ihelp_provider.js.map +1 -1
- package/dist/components/ihelp_rich_editor.js +2 -2
- package/dist/components/ihelp_rich_editor.js.map +1 -1
- package/dist/components/ihelp_search_dialog.d.ts.map +1 -1
- package/dist/components/ihelp_search_dialog.js +5 -3
- package/dist/components/ihelp_search_dialog.js.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/components/ui/badge.js +1 -1
- package/dist/components/ui/badge.js.map +1 -1
- package/dist/components/ui/button.js +1 -1
- package/dist/components/ui/button.js.map +1 -1
- package/dist/components/ui/dialog.js +1 -1
- package/dist/components/ui/dialog.js.map +1 -1
- package/dist/components/ui/input.js +1 -1
- package/dist/components/ui/input.js.map +1 -1
- package/dist/components/ui/label.js +1 -1
- package/dist/components/ui/label.js.map +1 -1
- package/dist/components/ui/popover.js +1 -1
- package/dist/components/ui/popover.js.map +1 -1
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/select.js.map +1 -1
- package/dist/components/ui/switch.js +1 -1
- package/dist/components/ui/switch.js.map +1 -1
- package/dist/components/ui/textarea.js +1 -1
- package/dist/components/ui/textarea.js.map +1 -1
- package/dist/index.client.d.ts +2 -2
- package/dist/index.client.d.ts.map +1 -1
- package/dist/index.client.js +1 -1
- package/dist/index.client.js.map +1 -1
- package/dist/lib/config/hazo_ihelp_config.d.ts +106 -0
- package/dist/lib/config/hazo_ihelp_config.d.ts.map +1 -0
- package/dist/lib/config/hazo_ihelp_config.js +66 -0
- package/dist/lib/config/hazo_ihelp_config.js.map +1 -0
- package/dist/types/index.d.ts +45 -34
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/logger.d.ts +25 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +42 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +74 -23
- package/config/hazo_ihelp_config.ini +0 -9
- package/dist/utils/cn.d.ts +0 -3
- package/dist/utils/cn.d.ts.map +0 -1
- package/dist/utils/cn.js +0 -6
- package/dist/utils/cn.js.map +0 -1
- 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
|
|
5
|
-
*
|
|
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
|
|
34
|
+
/** Generate a UUID for DB primary keys. Strips the req_ prefix from generateRequestId(). */
|
|
40
35
|
function generateUUID() {
|
|
41
|
-
return
|
|
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
|
-
|
|
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:
|
|
86
|
+
// Internal helper: normalise a DB row into a full IhelpTopic
|
|
72
87
|
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const
|
|
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
|
-
...
|
|
92
|
+
...row,
|
|
82
93
|
tags,
|
|
83
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
//
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
316
|
+
patch.tags = JSON.stringify(tags);
|
|
306
317
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
return
|
|
381
|
+
catch (error) {
|
|
382
|
+
return handleError(logger, 'topic.delete.failed', error);
|
|
352
383
|
}
|
|
353
|
-
|
|
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
|
}
|