hazo_admin 0.3.2 → 0.6.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 (42) hide show
  1. package/CHANGE_LOG.md +31 -0
  2. package/README.md +115 -2
  3. package/SETUP_CHECKLIST.md +4 -1
  4. package/config/hazo_admin_config.ini.sample +11 -0
  5. package/dist/api/index.d.ts +4 -0
  6. package/dist/api/index.d.ts.map +1 -1
  7. package/dist/api/index.js +174 -0
  8. package/dist/components/admin_app.d.ts.map +1 -1
  9. package/dist/components/admin_app.js +3 -0
  10. package/dist/components/admin_kinds.d.ts.map +1 -1
  11. package/dist/components/admin_kinds.js +9 -1
  12. package/dist/components/admin_nav.d.ts +2 -2
  13. package/dist/components/admin_nav.d.ts.map +1 -1
  14. package/dist/components/admin_nav.js +12 -0
  15. package/dist/components/issues_panel/index.d.ts +6 -0
  16. package/dist/components/issues_panel/index.d.ts.map +1 -0
  17. package/dist/components/issues_panel/index.js +130 -0
  18. package/dist/index.client.d.ts +4 -0
  19. package/dist/index.client.d.ts.map +1 -1
  20. package/dist/index.client.js +4 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/issues/archive_handler.d.ts +29 -0
  25. package/dist/issues/archive_handler.d.ts.map +1 -0
  26. package/dist/issues/archive_handler.js +35 -0
  27. package/dist/issues/index.d.ts +8 -0
  28. package/dist/issues/index.d.ts.map +1 -0
  29. package/dist/issues/index.js +4 -0
  30. package/dist/issues/registry.client.d.ts +32 -0
  31. package/dist/issues/registry.client.d.ts.map +1 -0
  32. package/dist/issues/registry.client.js +15 -0
  33. package/dist/issues/registry.d.ts +45 -0
  34. package/dist/issues/registry.d.ts.map +1 -0
  35. package/dist/issues/registry.js +11 -0
  36. package/dist/issues/routing.d.ts +17 -0
  37. package/dist/issues/routing.d.ts.map +1 -0
  38. package/dist/issues/routing.js +43 -0
  39. package/dist/issues/store.d.ts +72 -0
  40. package/dist/issues/store.d.ts.map +1 -0
  41. package/dist/issues/store.js +401 -0
  42. package/package.json +11 -6
@@ -0,0 +1,401 @@
1
+ import 'server-only';
2
+ import { createLogger } from 'hazo_core';
3
+ const log = createLogger('hazo_admin:issues');
4
+ const TABLE = 'hazo_admin_issues';
5
+ export function toIssueRecord(raw) {
6
+ return {
7
+ id: raw.id,
8
+ scope_id: raw.scope_id,
9
+ type: raw.type,
10
+ status: raw.status,
11
+ subject_user_id: raw.subject_user_id,
12
+ assigned_to: raw.assigned_to ?? null,
13
+ recipient_scope_id: raw.recipient_scope_id ?? null,
14
+ title: raw.title,
15
+ summary: raw.summary,
16
+ payload: typeof raw.payload === 'string'
17
+ ? JSON.parse(raw.payload)
18
+ : raw.payload ?? {},
19
+ dedupe_key: raw.dedupe_key,
20
+ occurrence_count: raw.occurrence_count,
21
+ first_seen_at: raw.first_seen_at,
22
+ last_seen_at: raw.last_seen_at,
23
+ resolution: raw.resolution ?? null,
24
+ resolution_role_id: raw.resolution_role_id ?? null,
25
+ resolution_reason: raw.resolution_reason ?? null,
26
+ resolved_by: raw.resolved_by ?? null,
27
+ resolved_at: raw.resolved_at ?? null,
28
+ created_at: raw.created_at,
29
+ updated_at: raw.updated_at,
30
+ };
31
+ }
32
+ /**
33
+ * Legal status transitions:
34
+ * new → wip
35
+ * new → closed (direct close)
36
+ * wip → closed
37
+ * any → archived (only via archiveClosedOlderThan)
38
+ */
39
+ const ALLOWED_TRANSITIONS = {
40
+ new: ['wip', 'closed'],
41
+ wip: ['closed'],
42
+ closed: [],
43
+ archived: [],
44
+ };
45
+ function assertTransition(from, to) {
46
+ const allowed = ALLOWED_TRANSITIONS[from] ?? [];
47
+ if (!allowed.includes(to)) {
48
+ throw new Error(`hazo_admin:issues — illegal status transition: ${from} → ${to}`);
49
+ }
50
+ }
51
+ export function createIssueStore(adapter) {
52
+ function now() {
53
+ return new Date().toISOString();
54
+ }
55
+ async function raw(sql, params = []) {
56
+ return adapter.raw(sql, params);
57
+ }
58
+ async function createOrBumpIssue(input) {
59
+ log.debug('createOrBumpIssue', { dedupe_key: input.dedupe_key });
60
+ // Check for an existing open issue with this dedupe_key.
61
+ const existing = await raw(`SELECT * FROM ${TABLE} WHERE dedupe_key = $1 AND status NOT IN ('closed', 'archived') LIMIT 1`, [input.dedupe_key]);
62
+ if (existing && existing.length > 0) {
63
+ const ts = now();
64
+ const bumped = await raw(`UPDATE ${TABLE}
65
+ SET occurrence_count = occurrence_count + 1,
66
+ last_seen_at = $1,
67
+ updated_at = $2
68
+ WHERE id = $3
69
+ RETURNING *`, [ts, ts, existing[0].id]);
70
+ const row = (bumped && bumped.length > 0 ? bumped[0] : existing[0]);
71
+ return { issue: toIssueRecord(row), isNew: false };
72
+ }
73
+ const ts = now();
74
+ const id = crypto.randomUUID();
75
+ const payloadStr = JSON.stringify(input.payload);
76
+ const inserted = await raw(`INSERT INTO ${TABLE} (
77
+ id, scope_id, type, status, subject_user_id, assigned_to,
78
+ recipient_scope_id, title, summary, payload, dedupe_key,
79
+ occurrence_count, first_seen_at, last_seen_at,
80
+ resolution, resolution_role_id, resolution_reason,
81
+ resolved_by, resolved_at, created_at, updated_at
82
+ ) VALUES (
83
+ $1, $2, $3, 'new', $4, NULL,
84
+ $5, $6, $7, $8, $9,
85
+ 1, $10, $11,
86
+ NULL, NULL, NULL,
87
+ NULL, NULL, $12, $13
88
+ ) RETURNING *`, [
89
+ id,
90
+ input.scope_id,
91
+ input.type,
92
+ input.subject_user_id,
93
+ input.recipient_scope_id ?? null,
94
+ input.title,
95
+ input.summary,
96
+ payloadStr,
97
+ input.dedupe_key,
98
+ ts,
99
+ ts,
100
+ ts,
101
+ ts,
102
+ ]);
103
+ const row = (inserted && inserted.length > 0 ? inserted[0] : null);
104
+ if (!row) {
105
+ throw new Error('hazo_admin:issues — INSERT returned no row');
106
+ }
107
+ return { issue: toIssueRecord(row), isNew: true };
108
+ }
109
+ async function listIssues(opts) {
110
+ const { adminScopeIds, isGlobalAdmin = false, status, assignedTo, type, limit = 50, offset = 0, } = opts;
111
+ if (!isGlobalAdmin && (!adminScopeIds || adminScopeIds.length === 0)) {
112
+ return [];
113
+ }
114
+ const conditions = [];
115
+ const params = [];
116
+ let paramIdx = 1;
117
+ if (!isGlobalAdmin && adminScopeIds && adminScopeIds.length > 0) {
118
+ const placeholders = adminScopeIds.map(() => `$${paramIdx++}`).join(', ');
119
+ conditions.push(`scope_id IN (${placeholders})`);
120
+ params.push(...adminScopeIds);
121
+ }
122
+ if (status !== undefined) {
123
+ const statuses = Array.isArray(status) ? status : [status];
124
+ const placeholders = statuses.map(() => `$${paramIdx++}`).join(', ');
125
+ conditions.push(`status IN (${placeholders})`);
126
+ params.push(...statuses);
127
+ }
128
+ if (assignedTo !== undefined) {
129
+ if (assignedTo === null) {
130
+ conditions.push('assigned_to IS NULL');
131
+ }
132
+ else {
133
+ conditions.push(`assigned_to = $${paramIdx++}`);
134
+ params.push(assignedTo);
135
+ }
136
+ }
137
+ if (type !== undefined) {
138
+ conditions.push(`type = $${paramIdx++}`);
139
+ params.push(type);
140
+ }
141
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
142
+ params.push(limit);
143
+ params.push(offset);
144
+ const sql = `SELECT * FROM ${TABLE} ${where} ORDER BY last_seen_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`;
145
+ const rows = await raw(sql, params);
146
+ return (rows ?? []).map(toIssueRecord);
147
+ }
148
+ async function getIssue(id) {
149
+ const rows = await raw(`SELECT * FROM ${TABLE} WHERE id = $1 LIMIT 1`, [id]);
150
+ if (!rows || rows.length === 0)
151
+ return null;
152
+ return toIssueRecord(rows[0]);
153
+ }
154
+ async function transitionStatus(id, status, actorUserId) {
155
+ const issue = await getIssue(id);
156
+ if (!issue)
157
+ throw new Error(`hazo_admin:issues — issue ${id} not found`);
158
+ assertTransition(issue.status, status);
159
+ const ts = now();
160
+ const patch = { status, updated_at: ts };
161
+ // On →wip: auto-assign to actor if not yet assigned.
162
+ if (status === 'wip' && issue.assigned_to === null) {
163
+ patch.assigned_to = actorUserId;
164
+ }
165
+ const setClauses = Object.keys(patch)
166
+ .map((k, i) => `${k} = $${i + 2}`)
167
+ .join(', ');
168
+ const updated = await raw(`UPDATE ${TABLE} SET ${setClauses} WHERE id = $1 RETURNING *`, [id, ...Object.values(patch)]);
169
+ const row = updated && updated.length > 0 ? updated[0] : null;
170
+ if (!row)
171
+ throw new Error(`hazo_admin:issues — UPDATE returned no row for ${id}`);
172
+ return toIssueRecord(row);
173
+ }
174
+ async function setAssignee(id, userId) {
175
+ const ts = now();
176
+ const updated = await raw(`UPDATE ${TABLE} SET assigned_to = $1, updated_at = $2 WHERE id = $3 RETURNING *`, [userId, ts, id]);
177
+ const row = updated && updated.length > 0 ? updated[0] : null;
178
+ if (!row)
179
+ throw new Error(`hazo_admin:issues — issue ${id} not found`);
180
+ return toIssueRecord(row);
181
+ }
182
+ async function resolveIssue(id, opts) {
183
+ const ts = now();
184
+ const updated = await raw(`UPDATE ${TABLE}
185
+ SET status = 'closed',
186
+ resolution = $1,
187
+ resolution_role_id = $2,
188
+ resolution_reason = $3,
189
+ resolved_by = $4,
190
+ resolved_at = $5,
191
+ updated_at = $6
192
+ WHERE id = $7
193
+ RETURNING *`, [
194
+ opts.resolution,
195
+ opts.role_id ?? null,
196
+ opts.reason ?? null,
197
+ opts.resolved_by,
198
+ ts,
199
+ ts,
200
+ id,
201
+ ]);
202
+ const row = updated && updated.length > 0 ? updated[0] : null;
203
+ if (!row)
204
+ throw new Error(`hazo_admin:issues — issue ${id} not found`);
205
+ return toIssueRecord(row);
206
+ }
207
+ async function archiveClosedOlderThan(cutoffDate) {
208
+ const cutoff = cutoffDate.toISOString();
209
+ log.debug('archiveClosedOlderThan', { cutoff });
210
+ const result = await raw(`UPDATE ${TABLE}
211
+ SET status = 'archived', updated_at = $1
212
+ WHERE status = 'closed' AND resolved_at < $2
213
+ RETURNING id`, [now(), cutoff]);
214
+ const count = result ? result.length : 0;
215
+ log.debug('archiveClosedOlderThan: archived', { count });
216
+ return count;
217
+ }
218
+ return {
219
+ createOrBumpIssue,
220
+ listIssues,
221
+ getIssue,
222
+ transitionStatus,
223
+ setAssignee,
224
+ resolveIssue,
225
+ archiveClosedOlderThan,
226
+ };
227
+ }
228
+ /**
229
+ * PostgREST-native implementation of IssueStore.
230
+ * Uses createCrudService + adapter.claimRows so no raw SQL is issued.
231
+ * Falls back to createIssueStore when the adapter exposes a raw() method
232
+ * (i.e. it is a direct-DB adapter that already handles SQL).
233
+ *
234
+ * Returns a Promise because it lazily imports hazo_connect/server on the
235
+ * CRUD path (avoiding loading the full hazo_connect stack for raw adapters).
236
+ */
237
+ export async function createIssueStoreFromConnect(adapter) {
238
+ // Capability sniff: direct-DB adapters have a raw() method — use the raw store.
239
+ if (typeof adapter.raw === 'function') {
240
+ return createIssueStore(adapter);
241
+ }
242
+ // Lazy import to avoid pulling hazo_connect into server-only environments
243
+ // that don't have it installed (e.g., direct-SQL test adapters).
244
+ const { createCrudService } = await import('hazo_connect/server');
245
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
246
+ const crud = createCrudService(adapter, TABLE);
247
+ function now() {
248
+ return new Date().toISOString();
249
+ }
250
+ async function createOrBumpIssue(input) {
251
+ log.debug('createIssueStoreFromConnect:createOrBumpIssue', { dedupe_key: input.dedupe_key });
252
+ // Atomically bump an existing open issue via claimRows.
253
+ const bumped = await adapter.claimRows({
254
+ table: TABLE,
255
+ where: {
256
+ dedupe_key: input.dedupe_key,
257
+ status: { in: ['new', 'wip'] },
258
+ },
259
+ set: {
260
+ occurrence_count: { increment: 1 },
261
+ last_seen_at: { $now: true },
262
+ updated_at: { $now: true },
263
+ },
264
+ returning: ['*'],
265
+ });
266
+ if (bumped && bumped.length > 0) {
267
+ return { issue: toIssueRecord(bumped[0]), isNew: false };
268
+ }
269
+ // No open issue found — insert a new one.
270
+ const ts = now();
271
+ const inserted = await crud.insert({
272
+ scope_id: input.scope_id,
273
+ type: input.type,
274
+ status: 'new',
275
+ subject_user_id: input.subject_user_id,
276
+ assigned_to: null,
277
+ recipient_scope_id: input.recipient_scope_id ?? null,
278
+ title: input.title,
279
+ summary: input.summary,
280
+ payload: input.payload,
281
+ dedupe_key: input.dedupe_key,
282
+ occurrence_count: 1,
283
+ first_seen_at: ts,
284
+ last_seen_at: ts,
285
+ resolution: null,
286
+ resolution_role_id: null,
287
+ resolution_reason: null,
288
+ resolved_by: null,
289
+ resolved_at: null,
290
+ created_at: ts,
291
+ updated_at: ts,
292
+ });
293
+ const row = Array.isArray(inserted) ? inserted[0] : inserted;
294
+ if (!row)
295
+ throw new Error('hazo_admin:issues — INSERT returned no row');
296
+ return { issue: toIssueRecord(row), isNew: true };
297
+ }
298
+ async function listIssues(opts) {
299
+ const { adminScopeIds, isGlobalAdmin = false, status, assignedTo, type, limit = 50, offset = 0, } = opts;
300
+ if (!isGlobalAdmin && (!adminScopeIds || adminScopeIds.length === 0)) {
301
+ return [];
302
+ }
303
+ const qb = crud.query();
304
+ if (!isGlobalAdmin && adminScopeIds && adminScopeIds.length > 0) {
305
+ qb.where('scope_id', 'in', adminScopeIds);
306
+ }
307
+ if (status !== undefined) {
308
+ const statuses = Array.isArray(status) ? status : [status];
309
+ qb.where('status', 'in', statuses);
310
+ }
311
+ if (assignedTo !== undefined) {
312
+ if (assignedTo === null) {
313
+ qb.where('assigned_to', 'is', null);
314
+ }
315
+ else {
316
+ qb.where('assigned_to', 'eq', assignedTo);
317
+ }
318
+ }
319
+ if (type !== undefined) {
320
+ qb.where('type', 'eq', type);
321
+ }
322
+ qb.order('last_seen_at', 'desc').limit(limit).offset(offset);
323
+ const rows = await qb.execute('GET');
324
+ return (Array.isArray(rows) ? rows : []).map((r) => toIssueRecord(r));
325
+ }
326
+ async function getIssue(id) {
327
+ const row = await crud.findById(id);
328
+ if (!row)
329
+ return null;
330
+ return toIssueRecord(row);
331
+ }
332
+ async function transitionStatus(id, status, actorUserId) {
333
+ const issue = await getIssue(id);
334
+ if (!issue)
335
+ throw new Error(`hazo_admin:issues — issue ${id} not found`);
336
+ assertTransition(issue.status, status);
337
+ const ts = now();
338
+ const patch = { status, updated_at: ts };
339
+ if (status === 'wip' && issue.assigned_to === null) {
340
+ patch.assigned_to = actorUserId;
341
+ }
342
+ const updated = await crud.updateById(id, patch);
343
+ const row = Array.isArray(updated) ? updated[0] : updated;
344
+ if (!row)
345
+ throw new Error(`hazo_admin:issues — UPDATE returned no row for ${id}`);
346
+ return toIssueRecord(row);
347
+ }
348
+ async function setAssignee(id, userId) {
349
+ const ts = now();
350
+ const updated = await crud.updateById(id, { assigned_to: userId, updated_at: ts });
351
+ const row = Array.isArray(updated) ? updated[0] : updated;
352
+ if (!row)
353
+ throw new Error(`hazo_admin:issues — issue ${id} not found`);
354
+ return toIssueRecord(row);
355
+ }
356
+ async function resolveIssue(id, opts) {
357
+ const ts = now();
358
+ const updated = await crud.updateById(id, {
359
+ status: 'closed',
360
+ resolution: opts.resolution,
361
+ resolution_role_id: opts.role_id ?? null,
362
+ resolution_reason: opts.reason ?? null,
363
+ resolved_by: opts.resolved_by,
364
+ resolved_at: ts,
365
+ updated_at: ts,
366
+ });
367
+ const row = Array.isArray(updated) ? updated[0] : updated;
368
+ if (!row)
369
+ throw new Error(`hazo_admin:issues — issue ${id} not found`);
370
+ return toIssueRecord(row);
371
+ }
372
+ async function archiveClosedOlderThan(cutoffDate) {
373
+ const cutoff = cutoffDate.toISOString();
374
+ log.debug('createIssueStoreFromConnect:archiveClosedOlderThan', { cutoff });
375
+ const archived = await adapter.claimRows({
376
+ table: TABLE,
377
+ where: {
378
+ status: 'closed',
379
+ resolved_at: { lte: cutoff },
380
+ },
381
+ set: {
382
+ status: 'archived',
383
+ updated_at: { $now: true },
384
+ },
385
+ returning: ['id'],
386
+ limit: 1000,
387
+ });
388
+ const count = (archived ?? []).length;
389
+ log.debug('createIssueStoreFromConnect:archiveClosedOlderThan — archived', { count });
390
+ return count;
391
+ }
392
+ return {
393
+ createOrBumpIssue,
394
+ listIssues,
395
+ getIssue,
396
+ transitionStatus,
397
+ setAssignee,
398
+ resolveIssue,
399
+ archiveClosedOlderThan,
400
+ };
401
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_admin",
3
- "version": "0.3.2",
3
+ "version": "0.6.0",
4
4
  "description": "Standard site-admin package — auth-gated admin shell + panel kit + drop-in /admin preset",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -38,6 +38,7 @@
38
38
  },
39
39
  "files": [
40
40
  "dist",
41
+ "config/*.ini.sample",
41
42
  "README.md",
42
43
  "CHANGE_LOG.md",
43
44
  "SETUP_CHECKLIST.md"
@@ -51,15 +52,15 @@
51
52
  },
52
53
  "peerDependencies": {
53
54
  "hazo_core": "^1.2.0",
54
- "hazo_ui": "^4.0.0",
55
+ "hazo_ui": "^4.4.0",
55
56
  "react": "^18.0.0 || ^19.0.0",
56
57
  "react-dom": "^18.0.0 || ^19.0.0",
57
58
  "next": "^14.0.0 || ^16.0.0",
58
- "hazo_auth": "^10.2.0",
59
+ "hazo_auth": "^10.2.0 || ^10.3.0",
59
60
  "hazo_logs": "^2.0.2",
60
61
  "hazo_debug": "^3.1.1",
61
62
  "hazo_connect": "^3.7.0",
62
- "hazo_config": "^2.1.7",
63
+ "hazo_config": "^2.2.0",
63
64
  "hazo_api": "^2.3.1",
64
65
  "hazo_files": "^3.0.0",
65
66
  "hazo_jobs": "^0.12.0",
@@ -69,6 +70,7 @@
69
70
  "hazo_secure": "^1.2.0",
70
71
  "hazo_blog": "^0.2.0",
71
72
  "hazo_testing": "^0.3.1",
73
+ "hazo_notify": "^6.1.4",
72
74
  "lucide-react": "^0.553.0"
73
75
  },
74
76
  "peerDependenciesMeta": {
@@ -113,6 +115,9 @@
113
115
  },
114
116
  "hazo_testing": {
115
117
  "optional": true
118
+ },
119
+ "hazo_notify": {
120
+ "optional": true
116
121
  }
117
122
  },
118
123
  "devDependencies": {
@@ -126,8 +131,8 @@
126
131
  "@types/react": "^19.0.0",
127
132
  "@types/react-dom": "^19.0.0",
128
133
  "hazo_core": "^1.2.0",
129
- "hazo_ui": "^4.0.0",
130
- "hazo_auth": "^10.2.0",
134
+ "hazo_ui": "^4.4.0",
135
+ "hazo_auth": "^10.3.0",
131
136
  "hazo_testing": "^0.3.1",
132
137
  "react": "^19.0.0",
133
138
  "react-dom": "^19.0.0",