hazo_admin 0.3.1 → 0.5.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/CHANGE_LOG.md +26 -0
- package/README.md +115 -2
- package/SETUP_CHECKLIST.md +4 -1
- package/config/hazo_admin_config.ini.sample +11 -0
- package/dist/api/index.d.ts +4 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +174 -0
- package/dist/components/admin_app.d.ts.map +1 -1
- package/dist/components/admin_app.js +3 -0
- package/dist/components/admin_kinds.d.ts.map +1 -1
- package/dist/components/admin_kinds.js +9 -1
- package/dist/components/admin_nav.d.ts +2 -2
- package/dist/components/admin_nav.d.ts.map +1 -1
- package/dist/components/admin_nav.js +12 -0
- package/dist/components/issues_panel/index.d.ts +6 -0
- package/dist/components/issues_panel/index.d.ts.map +1 -0
- package/dist/components/issues_panel/index.js +130 -0
- package/dist/index.client.d.ts +4 -0
- package/dist/index.client.d.ts.map +1 -1
- package/dist/index.client.js +4 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/issues/archive_handler.d.ts +29 -0
- package/dist/issues/archive_handler.d.ts.map +1 -0
- package/dist/issues/archive_handler.js +35 -0
- package/dist/issues/index.d.ts +8 -0
- package/dist/issues/index.d.ts.map +1 -0
- package/dist/issues/index.js +4 -0
- package/dist/issues/registry.client.d.ts +32 -0
- package/dist/issues/registry.client.d.ts.map +1 -0
- package/dist/issues/registry.client.js +15 -0
- package/dist/issues/registry.d.ts +45 -0
- package/dist/issues/registry.d.ts.map +1 -0
- package/dist/issues/registry.js +11 -0
- package/dist/issues/routing.d.ts +17 -0
- package/dist/issues/routing.d.ts.map +1 -0
- package/dist/issues/routing.js +43 -0
- package/dist/issues/store.d.ts +61 -0
- package/dist/issues/store.d.ts.map +1 -0
- package/dist/issues/store.js +227 -0
- package/package.json +15 -10
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createLogger } from 'hazo_core';
|
|
3
|
+
const log = createLogger('hazo_admin:issues');
|
|
4
|
+
const TABLE = 'hazo_admin_issues';
|
|
5
|
+
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hazo_admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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"
|
|
@@ -50,15 +51,15 @@
|
|
|
50
51
|
"build:test-app": "npm run build && cd test-app && npm run build"
|
|
51
52
|
},
|
|
52
53
|
"peerDependencies": {
|
|
53
|
-
"hazo_core": "^1.
|
|
54
|
-
"hazo_ui": "^
|
|
54
|
+
"hazo_core": "^1.2.0",
|
|
55
|
+
"hazo_ui": "^4.0.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.
|
|
59
|
+
"hazo_auth": "^10.2.0 || ^10.3.0",
|
|
59
60
|
"hazo_logs": "^2.0.2",
|
|
60
61
|
"hazo_debug": "^3.1.1",
|
|
61
|
-
"hazo_connect": "^3.
|
|
62
|
+
"hazo_connect": "^3.7.0",
|
|
62
63
|
"hazo_config": "^2.1.7",
|
|
63
64
|
"hazo_api": "^2.3.1",
|
|
64
65
|
"hazo_files": "^3.0.0",
|
|
@@ -68,7 +69,8 @@
|
|
|
68
69
|
"hazo_feedback": "^2.2.0",
|
|
69
70
|
"hazo_secure": "^1.2.0",
|
|
70
71
|
"hazo_blog": "^0.2.0",
|
|
71
|
-
"hazo_testing": "^0.3.
|
|
72
|
+
"hazo_testing": "^0.3.1",
|
|
73
|
+
"hazo_notify": "^6.1.0",
|
|
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": {
|
|
@@ -125,10 +130,10 @@
|
|
|
125
130
|
"@types/node": "^22.10.0",
|
|
126
131
|
"@types/react": "^19.0.0",
|
|
127
132
|
"@types/react-dom": "^19.0.0",
|
|
128
|
-
"hazo_core": "^1.
|
|
129
|
-
"hazo_ui": "^3.
|
|
130
|
-
"hazo_auth": "^10.
|
|
131
|
-
"hazo_testing": "^0.3.
|
|
133
|
+
"hazo_core": "^1.2.0",
|
|
134
|
+
"hazo_ui": "^4.3.1",
|
|
135
|
+
"hazo_auth": "^10.3.0",
|
|
136
|
+
"hazo_testing": "^0.3.1",
|
|
132
137
|
"react": "^19.0.0",
|
|
133
138
|
"react-dom": "^19.0.0",
|
|
134
139
|
"next": "^16.0.10",
|