hazo_admin 0.3.2 → 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.
Files changed (42) hide show
  1. package/CHANGE_LOG.md +17 -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 +61 -0
  40. package/dist/issues/store.d.ts.map +1 -0
  41. package/dist/issues/store.js +227 -0
  42. package/package.json +9 -4
@@ -1,3 +1,5 @@
1
+ export { registerIssueCardRenderer, getIssueCardRenderer, listIssueCardRenderers, DefaultIssueCard, } from './issues/registry.client.js';
2
+ export type { IssueCardData, IssueCardRenderer, IssueCardRendererProps, } from './issues/registry.client.js';
1
3
  export declare const HAZO_ADMIN_PERMISSIONS: {
2
4
  readonly ADMIN_SYSTEM: "admin_system";
3
5
  readonly ENV_MIGRATE: "env.migrate";
@@ -10,6 +12,8 @@ export declare const HAZO_ADMIN_PERMISSIONS: {
10
12
  readonly FEEDBACK_REVIEW: "admin.feedback.review";
11
13
  readonly METRICS_VIEW: "metrics.view";
12
14
  readonly METRICS_MANAGE: "metrics.manage";
15
+ readonly ADMIN_ISSUE_TRIAGE: "admin_issue_triage";
16
+ readonly ADMIN_USER_SCOPE_ASSIGNMENT: "admin_user_scope_assignment";
13
17
  };
14
18
  export type HazoAdminPermissionKey = keyof typeof HAZO_ADMIN_PERMISSIONS;
15
19
  export type HazoAdminPermission = (typeof HAZO_ADMIN_PERMISSIONS)[HazoAdminPermissionKey];
@@ -1 +1 @@
1
- {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB;;;;;;;;;;;;CAYzB,CAAC;AAEX,MAAM,MAAM,sBAAsB,GAAG,MAAM,OAAO,sBAAsB,CAAC;AACzE,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,sBAAsB,CAAC,CAAC"}
1
+ {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AACA,OAAO,EACL,yBAAyB,EACzB,oBAAoB,EACpB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,6BAA6B,CAAC;AACrC,YAAY,EACV,aAAa,EACb,iBAAiB,EACjB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AAErC,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;CAczB,CAAC;AAEX,MAAM,MAAM,sBAAsB,GAAG,MAAM,OAAO,sBAAsB,CAAC;AACzE,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,sBAAsB,CAAC,CAAC"}
@@ -1,3 +1,5 @@
1
+ // Client-side issue card renderer registry
2
+ export { registerIssueCardRenderer, getIssueCardRenderer, listIssueCardRenderers, DefaultIssueCard, } from './issues/registry.client.js';
1
3
  export const HAZO_ADMIN_PERMISSIONS = {
2
4
  ADMIN_SYSTEM: 'admin_system',
3
5
  ENV_MIGRATE: 'env.migrate',
@@ -10,4 +12,6 @@ export const HAZO_ADMIN_PERMISSIONS = {
10
12
  FEEDBACK_REVIEW: 'admin.feedback.review',
11
13
  METRICS_VIEW: 'metrics.view',
12
14
  METRICS_MANAGE: 'metrics.manage',
15
+ ADMIN_ISSUE_TRIAGE: 'admin_issue_triage',
16
+ ADMIN_USER_SCOPE_ASSIGNMENT: 'admin_user_scope_assignment',
13
17
  };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { adminGate, withAdminGate } from './lib/admin_gate.server.js';
2
2
  export type { AdminGateResult } from './lib/admin_gate.server.js';
3
3
  export { HAZO_ADMIN_PERMISSIONS } from './index.client.js';
4
+ export { createIssueStore, registerIssueType, getIssueType, listIssueTypes, loadIssueRoutingConfig, getRecipientStrategy, issueArchiveJobHandler, registerIssueArchiveJob, ADMIN_ISSUE_ARCHIVE_JOB_TYPE, } from './issues/index.js';
5
+ export type { IssueRecord, IssueStore, CreateIssueInput, IssueTypeDef, IssueActionDef, IssueActionCtx, IssueActionResult, RecipientResult, NotifyPayload, IssueRoutingConfig, RecipientStrategy, } from './issues/index.js';
4
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AACtE,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AACtE,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAE3D,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,sBAAsB,EACtB,uBAAuB,EACvB,4BAA4B,GAC7B,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,WAAW,EACX,UAAU,EACV,gBAAgB,EAChB,YAAY,EACZ,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { adminGate, withAdminGate } from './lib/admin_gate.server.js';
2
2
  export { HAZO_ADMIN_PERMISSIONS } from './index.client.js';
3
+ export { createIssueStore, registerIssueType, getIssueType, listIssueTypes, loadIssueRoutingConfig, getRecipientStrategy, issueArchiveJobHandler, registerIssueArchiveJob, ADMIN_ISSUE_ARCHIVE_JOB_TYPE, } from './issues/index.js';
@@ -0,0 +1,29 @@
1
+ import 'server-only';
2
+ export declare const ADMIN_ISSUE_ARCHIVE_JOB_TYPE: "admin.issue_archive";
3
+ /**
4
+ * Job handler for admin.issue_archive jobs.
5
+ *
6
+ * Payload: { cutoffMonths?: number } — defaults to 3 months.
7
+ *
8
+ * Moves all closed issues whose resolved_at is older than cutoffMonths months
9
+ * to status=archived.
10
+ */
11
+ export declare function issueArchiveJobHandler(job: {
12
+ id: string;
13
+ payload: unknown;
14
+ }, ctx: {
15
+ log: (...args: any[]) => void;
16
+ }, opts: {
17
+ getHazoConnect: () => Promise<any> | any;
18
+ }): Promise<{
19
+ archivedCount: number;
20
+ }>;
21
+ /**
22
+ * Register the admin.issue_archive job type with a hazo_jobs worker.
23
+ */
24
+ export declare function registerIssueArchiveJob(worker: {
25
+ register: (type: string, handler: (job: any, ctx: any) => Promise<unknown>) => void;
26
+ }, opts: {
27
+ getHazoConnect: () => Promise<any> | any;
28
+ }): void;
29
+ //# sourceMappingURL=archive_handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive_handler.d.ts","sourceRoot":"","sources":["../../src/issues/archive_handler.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAGrB,eAAO,MAAM,4BAA4B,EAAG,qBAA8B,CAAC;AAE3E;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,EACrC,GAAG,EAAE;IAAE,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;CAAE,EACtC,IAAI,EAAE;IAAE,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAA;CAAE,GACjD,OAAO,CAAC;IAAE,aAAa,EAAE,MAAM,CAAA;CAAE,CAAC,CA0BpC;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE;IACN,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;CACrF,EACD,IAAI,EAAE;IAAE,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAA;CAAE,GACjD,IAAI,CAMN"}
@@ -0,0 +1,35 @@
1
+ import 'server-only';
2
+ import { createIssueStore } from './store.js';
3
+ export const ADMIN_ISSUE_ARCHIVE_JOB_TYPE = 'admin.issue_archive';
4
+ /**
5
+ * Job handler for admin.issue_archive jobs.
6
+ *
7
+ * Payload: { cutoffMonths?: number } — defaults to 3 months.
8
+ *
9
+ * Moves all closed issues whose resolved_at is older than cutoffMonths months
10
+ * to status=archived.
11
+ */
12
+ export async function issueArchiveJobHandler(job, ctx, opts) {
13
+ ctx.log(`[admin.issue_archive] starting job ${job.id}`);
14
+ const raw = job.payload;
15
+ const payload = typeof raw === 'string'
16
+ ? JSON.parse(raw)
17
+ : raw && typeof raw === 'object'
18
+ ? raw
19
+ : {};
20
+ const cutoffMonths = typeof payload.cutoffMonths === 'number' ? payload.cutoffMonths : 3;
21
+ const cutoffDate = new Date();
22
+ cutoffDate.setMonth(cutoffDate.getMonth() - cutoffMonths);
23
+ ctx.log(`[admin.issue_archive] archiving issues closed before ${cutoffDate.toISOString()}`);
24
+ const connect = await opts.getHazoConnect();
25
+ const store = createIssueStore(connect);
26
+ const archivedCount = await store.archiveClosedOlderThan(cutoffDate);
27
+ ctx.log(`[admin.issue_archive] archived ${archivedCount} issue(s)`);
28
+ return { archivedCount };
29
+ }
30
+ /**
31
+ * Register the admin.issue_archive job type with a hazo_jobs worker.
32
+ */
33
+ export function registerIssueArchiveJob(worker, opts) {
34
+ worker.register(ADMIN_ISSUE_ARCHIVE_JOB_TYPE, (job, ctx) => issueArchiveJobHandler(job, ctx, opts));
35
+ }
@@ -0,0 +1,8 @@
1
+ export { createIssueStore } from './store.js';
2
+ export type { IssueRecord, IssueStore, CreateIssueInput } from './store.js';
3
+ export { registerIssueType, getIssueType, listIssueTypes } from './registry.js';
4
+ export type { IssueTypeDef, IssueActionDef, IssueActionCtx, IssueActionResult, RecipientResult, NotifyPayload, } from './registry.js';
5
+ export { loadIssueRoutingConfig, getRecipientStrategy } from './routing.js';
6
+ export type { IssueRoutingConfig, RecipientStrategy } from './routing.js';
7
+ export { issueArchiveJobHandler, registerIssueArchiveJob, ADMIN_ISSUE_ARCHIVE_JOB_TYPE, } from './archive_handler.js';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/issues/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC9C,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC5E,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAChF,YAAY,EACV,YAAY,EACZ,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,eAAe,EACf,aAAa,GACd,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAC5E,YAAY,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAC1E,OAAO,EACL,sBAAsB,EACtB,uBAAuB,EACvB,4BAA4B,GAC7B,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { createIssueStore } from './store.js';
2
+ export { registerIssueType, getIssueType, listIssueTypes } from './registry.js';
3
+ export { loadIssueRoutingConfig, getRecipientStrategy } from './routing.js';
4
+ export { issueArchiveJobHandler, registerIssueArchiveJob, ADMIN_ISSUE_ARCHIVE_JOB_TYPE, } from './archive_handler.js';
@@ -0,0 +1,32 @@
1
+ import type React from 'react';
2
+ export interface IssueCardData {
3
+ id: string;
4
+ type: string;
5
+ status: string;
6
+ subject_user_id: string;
7
+ assigned_to: string | null;
8
+ title: string;
9
+ summary: string;
10
+ payload: Record<string, unknown>;
11
+ occurrence_count: number;
12
+ first_seen_at: string;
13
+ last_seen_at: string;
14
+ resolution: string | null;
15
+ resolution_reason: string | null;
16
+ resolved_by: string | null;
17
+ resolved_at: string | null;
18
+ }
19
+ export interface IssueCardRendererProps {
20
+ issue: IssueCardData;
21
+ basePath: string;
22
+ onAction?: (actionKey: string, params: Record<string, unknown>) => Promise<void>;
23
+ }
24
+ export type IssueCardRenderer = React.ComponentType<IssueCardRendererProps>;
25
+ export declare function registerIssueCardRenderer(typeKey: string, renderer: IssueCardRenderer): void;
26
+ export declare function getIssueCardRenderer(typeKey: string): IssueCardRenderer | undefined;
27
+ export declare function listIssueCardRenderers(): Array<{
28
+ typeKey: string;
29
+ renderer: IssueCardRenderer;
30
+ }>;
31
+ export declare function DefaultIssueCard({ issue }: IssueCardRendererProps): React.JSX.Element;
32
+ //# sourceMappingURL=registry.client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.client.d.ts","sourceRoot":"","sources":["../../src/issues/registry.client.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAG/B,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,gBAAgB,EAAE,MAAM,CAAC;IACzB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,aAAa,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClF;AAED,MAAM,MAAM,iBAAiB,GAAG,KAAK,CAAC,aAAa,CAAC,sBAAsB,CAAC,CAAC;AAI5E,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAE5F;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS,CAEnF;AAED,wBAAgB,sBAAsB,IAAI,KAAK,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,iBAAiB,CAAA;CAAE,CAAC,CAEhG;AAED,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,EAAE,sBAAsB,qBAWjE"}
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ const registry = new Map();
4
+ export function registerIssueCardRenderer(typeKey, renderer) {
5
+ registry.set(typeKey, renderer);
6
+ }
7
+ export function getIssueCardRenderer(typeKey) {
8
+ return registry.get(typeKey);
9
+ }
10
+ export function listIssueCardRenderers() {
11
+ return Array.from(registry.entries()).map(([typeKey, renderer]) => ({ typeKey, renderer }));
12
+ }
13
+ export function DefaultIssueCard({ issue }) {
14
+ return (_jsxs("div", { className: "text-sm space-y-1", children: [_jsx("div", { className: "font-medium text-gray-800", children: issue.title }), _jsx("div", { className: "text-gray-500 text-xs", children: issue.summary }), issue.occurrence_count > 1 && (_jsxs("div", { className: "text-xs text-amber-600", children: ["Occurred ", issue.occurrence_count, "\u00D7"] })), _jsx("div", { className: "text-xs text-gray-400", children: issue.type })] }));
15
+ }
@@ -0,0 +1,45 @@
1
+ import 'server-only';
2
+ import type { IssueRecord } from './store.js';
3
+ export type { IssueRecord };
4
+ export interface IssueActionCtx {
5
+ /** Injected by the API handler — provides access to hazo services. */
6
+ getHazoConnect: () => Promise<any> | any;
7
+ actorUserId: string;
8
+ actorPermissions: string[];
9
+ }
10
+ export interface IssueActionResult {
11
+ success: boolean;
12
+ message?: string;
13
+ data?: Record<string, unknown>;
14
+ }
15
+ export interface RecipientResult {
16
+ user_ids: string[];
17
+ scope_id: string;
18
+ }
19
+ export interface NotifyPayload {
20
+ subject: string;
21
+ body: string;
22
+ deep_link?: string;
23
+ payload?: Record<string, unknown>;
24
+ }
25
+ export interface IssueActionDef {
26
+ key: string;
27
+ label: string;
28
+ requiredPermission: string;
29
+ run(issue: IssueRecord, params: Record<string, unknown>, ctx: IssueActionCtx): Promise<IssueActionResult>;
30
+ }
31
+ export interface IssueTypeDef {
32
+ typeKey: string;
33
+ label: string;
34
+ buildDescriptor(payload: Record<string, unknown>): {
35
+ title: string;
36
+ summary: string;
37
+ };
38
+ resolveRecipients(issue: IssueRecord, ctx: IssueActionCtx): Promise<RecipientResult>;
39
+ actions: IssueActionDef[];
40
+ buildResolutionNotice(issue: IssueRecord, actionKey: string, result: IssueActionResult): NotifyPayload;
41
+ }
42
+ export declare function registerIssueType(def: IssueTypeDef): void;
43
+ export declare function getIssueType(typeKey: string): IssueTypeDef | undefined;
44
+ export declare function listIssueTypes(): IssueTypeDef[];
45
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/issues/registry.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,YAAY,EAAE,WAAW,EAAE,CAAC;AAE5B,MAAM,WAAW,cAAc;IAC7B,sEAAsE;IACtE,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,kBAAkB,EAAE,MAAM,CAAC;IAC3B,GAAG,CACD,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,iBAAiB,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACtF,iBAAiB,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IACrF,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,qBAAqB,CACnB,KAAK,EAAE,WAAW,EAClB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,iBAAiB,GACxB,aAAa,CAAC;CAClB;AAID,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAEzD;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAEtE;AAED,wBAAgB,cAAc,IAAI,YAAY,EAAE,CAE/C"}
@@ -0,0 +1,11 @@
1
+ import 'server-only';
2
+ const registry = new Map();
3
+ export function registerIssueType(def) {
4
+ registry.set(def.typeKey, def);
5
+ }
6
+ export function getIssueType(typeKey) {
7
+ return registry.get(typeKey);
8
+ }
9
+ export function listIssueTypes() {
10
+ return Array.from(registry.values());
11
+ }
@@ -0,0 +1,17 @@
1
+ import 'server-only';
2
+ export type RecipientStrategy = 'nearest_scope_admin';
3
+ export interface IssueRoutingConfig {
4
+ [typeKey: string]: RecipientStrategy;
5
+ }
6
+ /**
7
+ * Loads routing config from hazo_admin_config.ini [issue_routing] section.
8
+ * Falls back to { auth_permission: 'nearest_scope_admin' } if hazo_config is not installed
9
+ * or the section is absent.
10
+ */
11
+ export declare function loadIssueRoutingConfig(): IssueRoutingConfig;
12
+ /**
13
+ * Given an issue type key, look up the routing strategy from the loaded config.
14
+ * Returns null if no strategy is configured for this type.
15
+ */
16
+ export declare function getRecipientStrategy(typeKey: string): RecipientStrategy | null;
17
+ //# sourceMappingURL=routing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routing.d.ts","sourceRoot":"","sources":["../../src/issues/routing.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAErB,MAAM,MAAM,iBAAiB,GAAG,qBAAqB,CAAC;AAEtD,MAAM,WAAW,kBAAkB;IACjC,CAAC,OAAO,EAAE,MAAM,GAAG,iBAAiB,CAAC;CACtC;AAQD;;;;GAIG;AACH,wBAAgB,sBAAsB,IAAI,kBAAkB,CA2B3D;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAG9E"}
@@ -0,0 +1,43 @@
1
+ import 'server-only';
2
+ const DEFAULT_ROUTING = {
3
+ auth_permission: 'nearest_scope_admin',
4
+ };
5
+ let _cachedConfig = null;
6
+ /**
7
+ * Loads routing config from hazo_admin_config.ini [issue_routing] section.
8
+ * Falls back to { auth_permission: 'nearest_scope_admin' } if hazo_config is not installed
9
+ * or the section is absent.
10
+ */
11
+ export function loadIssueRoutingConfig() {
12
+ if (_cachedConfig !== null)
13
+ return _cachedConfig;
14
+ try {
15
+ // Lazy import — hazo_config is an optional peer dep.
16
+ // Use require-style dynamic import via a variable to avoid static analysis bundling it.
17
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
18
+ const hazoConfigMod = require('hazo_config');
19
+ const config = new hazoConfigMod.HazoConfig('hazo_admin_config.ini');
20
+ const result = { ...DEFAULT_ROUTING };
21
+ // hazo_config doesn't expose a section-dump API, so we read known keys.
22
+ // Consumers can extend by overriding loadIssueRoutingConfig entirely.
23
+ const authPerm = config.get('issue_routing', 'auth_permission');
24
+ if (authPerm && (authPerm === 'nearest_scope_admin')) {
25
+ result.auth_permission = authPerm;
26
+ }
27
+ _cachedConfig = result;
28
+ return result;
29
+ }
30
+ catch {
31
+ // hazo_config not installed or config file not found — use defaults.
32
+ _cachedConfig = { ...DEFAULT_ROUTING };
33
+ return _cachedConfig;
34
+ }
35
+ }
36
+ /**
37
+ * Given an issue type key, look up the routing strategy from the loaded config.
38
+ * Returns null if no strategy is configured for this type.
39
+ */
40
+ export function getRecipientStrategy(typeKey) {
41
+ const config = loadIssueRoutingConfig();
42
+ return config[typeKey] ?? null;
43
+ }
@@ -0,0 +1,61 @@
1
+ import 'server-only';
2
+ export interface IssueRecord {
3
+ id: string;
4
+ scope_id: string;
5
+ type: string;
6
+ status: 'new' | 'wip' | 'closed' | 'archived';
7
+ subject_user_id: string;
8
+ assigned_to: string | null;
9
+ recipient_scope_id: string | null;
10
+ title: string;
11
+ summary: string;
12
+ payload: Record<string, unknown>;
13
+ dedupe_key: string;
14
+ occurrence_count: number;
15
+ first_seen_at: string;
16
+ last_seen_at: string;
17
+ resolution: 'granted' | 'denied' | null;
18
+ resolution_role_id: string | null;
19
+ resolution_reason: string | null;
20
+ resolved_by: string | null;
21
+ resolved_at: string | null;
22
+ created_at: string;
23
+ updated_at: string;
24
+ }
25
+ export interface CreateIssueInput {
26
+ scope_id: string;
27
+ type: string;
28
+ subject_user_id: string;
29
+ recipient_scope_id?: string;
30
+ title: string;
31
+ summary: string;
32
+ payload: Record<string, unknown>;
33
+ dedupe_key: string;
34
+ }
35
+ export interface IssueStore {
36
+ createOrBumpIssue(input: CreateIssueInput): Promise<{
37
+ issue: IssueRecord;
38
+ isNew: boolean;
39
+ }>;
40
+ listIssues(opts: {
41
+ adminScopeIds?: string[];
42
+ isGlobalAdmin?: boolean;
43
+ status?: IssueRecord['status'] | IssueRecord['status'][];
44
+ assignedTo?: string | null;
45
+ type?: string;
46
+ limit?: number;
47
+ offset?: number;
48
+ }): Promise<IssueRecord[]>;
49
+ getIssue(id: string): Promise<IssueRecord | null>;
50
+ transitionStatus(id: string, status: 'wip' | 'closed', actorUserId: string): Promise<IssueRecord>;
51
+ setAssignee(id: string, userId: string | null): Promise<IssueRecord>;
52
+ resolveIssue(id: string, opts: {
53
+ resolution: 'granted' | 'denied';
54
+ role_id?: string;
55
+ reason?: string;
56
+ resolved_by: string;
57
+ }): Promise<IssueRecord>;
58
+ archiveClosedOlderThan(cutoffDate: Date): Promise<number>;
59
+ }
60
+ export declare function createIssueStore(adapter: any): IssueStore;
61
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/issues/store.ts"],"names":[],"mappings":"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"}
@@ -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
+ }