hazo_admin 0.7.1 → 0.11.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 (67) hide show
  1. package/CHANGE_LOG.md +60 -0
  2. package/README.md +65 -4
  3. package/SETUP_CHECKLIST.md +24 -0
  4. package/dist/api/index.d.ts +6 -1
  5. package/dist/api/index.d.ts.map +1 -1
  6. package/dist/api/index.js +166 -2
  7. package/dist/components/issues_panel/board_columns.d.ts +17 -0
  8. package/dist/components/issues_panel/board_columns.d.ts.map +1 -0
  9. package/dist/components/issues_panel/board_columns.js +37 -0
  10. package/dist/components/issues_panel/card_assignee_control.d.ts +17 -0
  11. package/dist/components/issues_panel/card_assignee_control.d.ts.map +1 -0
  12. package/dist/components/issues_panel/card_assignee_control.js +51 -0
  13. package/dist/components/issues_panel/card_type_control.d.ts +18 -0
  14. package/dist/components/issues_panel/card_type_control.d.ts.map +1 -0
  15. package/dist/components/issues_panel/card_type_control.js +42 -0
  16. package/dist/components/issues_panel/facet_sidebar.d.ts +25 -0
  17. package/dist/components/issues_panel/facet_sidebar.d.ts.map +1 -0
  18. package/dist/components/issues_panel/facet_sidebar.js +72 -0
  19. package/dist/components/issues_panel/facet_topbar.d.ts +20 -0
  20. package/dist/components/issues_panel/facet_topbar.d.ts.map +1 -0
  21. package/dist/components/issues_panel/facet_topbar.js +42 -0
  22. package/dist/components/issues_panel/filter.d.ts +12 -0
  23. package/dist/components/issues_panel/filter.d.ts.map +1 -0
  24. package/dist/components/issues_panel/filter.js +41 -0
  25. package/dist/components/issues_panel/index.d.ts.map +1 -1
  26. package/dist/components/issues_panel/index.js +145 -43
  27. package/dist/components/issues_panel/manage_types_dialog.d.ts +28 -0
  28. package/dist/components/issues_panel/manage_types_dialog.d.ts.map +1 -0
  29. package/dist/components/issues_panel/manage_types_dialog.js +84 -0
  30. package/dist/components/issues_panel/ui_helpers.d.ts +21 -0
  31. package/dist/components/issues_panel/ui_helpers.d.ts.map +1 -0
  32. package/dist/components/issues_panel/ui_helpers.js +136 -0
  33. package/dist/components/issues_panel/use_issue_types.d.ts +30 -0
  34. package/dist/components/issues_panel/use_issue_types.d.ts.map +1 -0
  35. package/dist/components/issues_panel/use_issue_types.js +81 -0
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/index.ui.d.ts +2 -0
  40. package/dist/index.ui.d.ts.map +1 -1
  41. package/dist/index.ui.js +1 -0
  42. package/dist/issues/archive_handler.d.ts +1 -1
  43. package/dist/issues/archive_handler.js +2 -2
  44. package/dist/issues/index.d.ts +6 -0
  45. package/dist/issues/index.d.ts.map +1 -1
  46. package/dist/issues/index.js +4 -0
  47. package/dist/issues/notify_handler.d.ts +26 -0
  48. package/dist/issues/notify_handler.d.ts.map +1 -0
  49. package/dist/issues/notify_handler.js +97 -0
  50. package/dist/issues/raise.d.ts +44 -0
  51. package/dist/issues/raise.d.ts.map +1 -0
  52. package/dist/issues/raise.js +77 -0
  53. package/dist/issues/recipients.d.ts +20 -0
  54. package/dist/issues/recipients.d.ts.map +1 -0
  55. package/dist/issues/recipients.js +61 -0
  56. package/dist/issues/registry.client.d.ts +1 -0
  57. package/dist/issues/registry.client.d.ts.map +1 -1
  58. package/dist/issues/registry.client.js +4 -1
  59. package/dist/issues/registry.d.ts +3 -3
  60. package/dist/issues/registry.d.ts.map +1 -1
  61. package/dist/issues/store.d.ts +10 -5
  62. package/dist/issues/store.d.ts.map +1 -1
  63. package/dist/issues/store.js +58 -28
  64. package/dist/issues/type_catalog.d.ts +48 -0
  65. package/dist/issues/type_catalog.d.ts.map +1 -0
  66. package/dist/issues/type_catalog.js +185 -0
  67. package/package.json +4 -4
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # hazo_admin Changelog
2
2
 
3
+ ## [0.11.0] - 2026-07-04
4
+
5
+ ### Added
6
+ - **"On Hold" status** — issues now have an `on_hold` status alongside `new`/`wip`/`closed`. It renders as a parked lane at the end of the active board (New → WIP → Closed → On Hold) and appears in the facet sidebar's Status filter. The `hazo_admin_issues.status` column is a bare `TEXT`/enum-free string, so no migration is required.
7
+ - **"Assign to me" button** — each card's owner control gets a compact **Me** button. It POSTs the new `POST /issues/:id/assign-to-me`, which assigns the issue to the **authenticated actor derived from the admin gate** (never a client-supplied id), so the client needs no identity of its own to claim an issue.
8
+ - **Age filter facet** — a single-select **Age** section in the sidebar (`Under 1 day` / `1–7 days` / `7–30 days` / `Over 30 days`), bucketed on `first_seen_at` and combined with the other facets via AND. Exposed as `ageBucket()` in `filter.ts`.
9
+ - **Manual archive sweep** — an **Archive old** button in the panel header POSTs `POST /issues/archive-sweep`, moving closed issues older than the configured cutoff to `archived` and refreshing both boards. Restricted to **global admins** server-side (the sweep is not scope-filtered), returning `403` otherwise.
10
+
11
+ - **Horizontal filter bar** — the Issues panel's facet controls moved from a fixed 240 px left rail into a single top bar (`facet_topbar.tsx`), giving the Kanban board its full width. Search sits inline; **Type**, **Severity**, and **Age** collapse to dropdown chips with active-count badges (hazo_ui `Popover`); a **Clear · N** button and **Manage types** round out the row. Status is no longer a facet here (it's already the board's columns).
12
+
13
+ ### Changed
14
+ - **Owner picker → hazo_auth `UserPickerSelect`** — the per-card **Owner** control now uses the shared avatar + username dropdown from `hazo_auth/client` (≥ 10.5.0) and `GET /issues/assignees` now lists **all users** (name/email label + `profile_picture_url`) instead of only resolvable admins, so real names and profile pictures show in place of raw user ids. The picker's portalled list is pinned to `LIGHT_THEME_VARS`; the **Me** button is unchanged. Also fixes the trigger overflowing its card (missing `min-w-0` on the flex child).
15
+ - **Free-form status transitions** — the issue state machine no longer restricts moves. Any known status may transition to any other known status (only an *unknown* target is rejected as an illegal transition), so admins can reopen a closed issue (Closed → New / In progress), park one (→ On Hold), etc. The `→ wip` auto-assign side effect is unchanged.
16
+ - **Default archive age is now 1 month** (was 3) — both the `admin.issue_archive` job handler (`cutoffMonths` default) and `cfg.issues.archiveAfterMonths` default to `1`. Consumers running the archive job on a cron therefore auto-archive issues closed for more than a month by default.
17
+ - **Transparency fix** — the per-card **Owner**/**Type** `Select` popovers and the **Manage issue types** dialog were rendering with a see-through background: hazo_ui portals their content to `document.body`, escaping the panel root that carries `LIGHT_THEME_VARS`, so `--popover`/`--background` resolved empty. They now carry an explicit opaque background (`bg-white`) plus the pinned light-theme vars on the portaled node.
18
+
19
+ ### Migrating (cron auto-archive)
20
+ To auto-archive on a schedule, register the existing archive job with a hazo_jobs worker and schedule it (e.g. daily):
21
+ ```ts
22
+ import { registerIssueArchiveJob, ADMIN_ISSUE_ARCHIVE_JOB_TYPE } from 'hazo_admin';
23
+ registerIssueArchiveJob(worker, { getHazoConnect });
24
+ // then schedule ADMIN_ISSUE_ARCHIVE_JOB_TYPE ('admin.issue_archive') via your supervisor/cron.
25
+ ```
26
+ The manual **Archive old** button covers the same sweep on demand.
27
+
28
+ ## [0.10.0] - 2026-07-03
29
+
30
+ ### Added
31
+ - **`IssuesPanel` is now a first-class `hazo_admin/ui` export** — consuming apps can drop the Issues triage board in directly (`import { IssuesPanel } from 'hazo_admin/ui'`) without mounting the full `AdminApp` shell.
32
+ - **Per-card reassign UI** — each issue card now renders a `CardAssigneeControl` dropdown (hazo_ui `Select` with a native `<select>` fallback, mirroring the type control). Picking an admin POSTs `/issues/:id/assign`; picking "Unassigned" clears it. Local board state updates optimistically.
33
+ - **`GET /issues/assignees`** — returns `{ assignees: [{ user_id, label }] }`, the admins assignable to issues. Global admins get `resolveGlobalAdmins`; scoped admins get the union of `resolveScopeAdmins` over their admin scopes. Gated by the same `admin_issue_triage` permission as the rest of the issues group; results are deduped by `user_id`. Display labels are resolved through hazo_auth's `hazo_get_user_profiles` batch lookup (`label = name || email`). **hazo_auth is required** — if it is unavailable the endpoint returns `500` (`hazo_auth_required` / `hazo_auth_profile_lookup_failed`) rather than emitting bare `user_id`s; ids with no resolvable profile are dropped.
34
+ - **test-app** — new autotest scenarios exercising the "raise → see it on the board" flow (raise a global/scoped issue via `raiseIssue`, then assert it appears in the same `listIssues` query the kanban runs) and an assign/unassign store round-trip. The `/raise-issue` page raises global/scoped issues and links straight to the `/admin/issues` kanban; the seed adds triage-permissioned demo users so the reassign picker is populated.
35
+
36
+ ## [0.9.0] - 2026-07-03
37
+
38
+ ### Added
39
+ - **Type-grouped board mode** for the Issues Kanban — toggle between the existing status columns (new/wip/closed) and a new type-grouped view. In type mode, columns are the issue type catalog (by `sort_order`) plus a trailing "uncatalogued" column for any in-use type with no catalog row; dragging a card between type columns reclassifies its `type`.
40
+ - **Facet sidebar** — a left `<aside>` (status + type + severity + search) replaces the thin top `HazoUiKanbanFilter` bar, filtering the board client-side. Also opens the "Manage types" dialog.
41
+ - **Per-card type control** — a small `Select` on each card lets an admin reclassify a single issue's type without dragging.
42
+ - **Issue type catalog** — new persistent table `hazo_admin_issue_types` (`migrations/003_hazo_admin_issue_types.sql`) holding label/color/description/sort-order metadata, separate from the in-memory behavioral `registry.ts`. `createIssueTypeCatalogStore`/`createIssueTypeCatalogStoreFromConnect` (mirrors the `store.ts` two-impl pattern) manage it; a "Manage types" dialog provides CRUD from the panel.
43
+ - **`setType(id, type)`** on `IssueStore` (both raw-SQL and connect impls) — dedupe-safe, since the partial unique index is on `dedupe_key` only.
44
+ - **New API routes**: `POST /issues/:id/type` (reclassify an issue) and an `/issue-types` CRUD group (`GET` list, `POST` create, `PATCH /:key` update, `DELETE /:key`). Catalog reads reuse the existing `admin_issue_triage` permission; catalog mutations gate on a manage permission (defaults to the same) and additionally enforce scope-ownership — scoped admins may only touch rows in their own scope, while global/cross-scope rows require a global admin.
45
+ - **Note:** deleting a type from the catalog is non-destructive — existing issues keep their `type` string and simply render in the "uncatalogued" column instead of disappearing.
46
+
47
+ ## [0.8.0] - 2026-07-02
48
+
49
+ ### Added
50
+ - **`raiseIssue(connect, input, opts?)`** — the single documented ingest protocol for raising an issue into the `/admin` Kanban. Trusted-by-construction server-side seam (no HTTP ingest endpoint). Validates `type` against the `registerIssueType` registry, auto-derives `title`/`summary` from the type's `buildDescriptor(payload)` and `dedupe_key` from `${source ?? 'unknown'}:${type}:${title}` when omitted, and persists via `createIssueStoreFromConnect`.
51
+ - Scope routing is inferred: `scope_id` present ⇒ scoped issue (scope admins); omitted/`null` ⇒ global issue (super admins), stored under sentinel scope id `GLOBAL_ISSUE_SCOPE_ID` (`'global'`).
52
+ - `RaiseIssueInput` adds `severity` (`'low'|'medium'|'high'|'critical'`, default `'medium'`) and `source` (raising package/app id); `subject_user_id` is now nullable.
53
+ - **Async, best-effort notify job** — `raiseIssue` enqueues a `hazo_admin.issue.notify` job (`ADMIN_ISSUE_NOTIFY_JOB_TYPE`) only when the issue is newly created (`isNew`); dedupe bumps do not re-notify. `issueNotifyJobHandler` / `registerIssueNotifyJob(worker, { getHazoConnect })` load the issue, resolve recipients (the type's optional `resolveRecipients` hook first, else the new recipient helpers), and dispatch via `hazo_notify`. Silent no-op if `hazo_jobs`/`hazo_notify` are absent.
54
+ - **Recipient helpers** — `resolveGlobalAdmins(connect)` (users holding `hazo_org_global_admin`) and `resolveScopeAdmins(connect, scope_id, permission?)` (scope-local admins, default `admin_issue_triage`), plus `GLOBAL_ADMIN_PERMISSION`, `SCOPE_ADMIN_PERMISSION`, `GLOBAL_ISSUE_SCOPE_ID` constants.
55
+ - **Relaxed `IssueTypeDef`** — `actions`, `resolveRecipients`, `buildResolutionNotice` are now optional, so a triage-only type with no resolution workflow is valid. `POST /issues/:id/resolve` returns `422` (`"has no resolution actions"`) for such a type.
56
+ - **Migration `002_add_issue_severity_source.sql`** — adds `severity` and `source` columns and drops the `NOT NULL` constraint on `subject_user_id` (PostgreSQL) on `hazo_admin_issues`. Migration `001`'s `CREATE TABLE` updated in place for fresh installs (both PostgreSQL and SQLite).
57
+ - `design/issue_raising_protocol.md` — full protocol doc for the above.
58
+
59
+ ### Fixed
60
+ - Global-admin detection in the issues list API filter standardized from `admin_system` to `hazo_org_global_admin` (the real hazo_auth super-admin permission), so global-scope visibility works as documented.
61
+ - `IssuesPanel` now reads the `{ issues: [...] }` API response shape (was reading `.data`, so the Kanban board rendered empty).
62
+
3
63
  ## [0.6.4] - 2026-06-26
4
64
 
5
65
  ### Fixed
package/README.md CHANGED
@@ -217,23 +217,48 @@ auth_permission = nearest_scope_admin
217
217
  |---|---|---|
218
218
  | `GET` | `/issues` | List issues (filtered by admin's scope) |
219
219
  | `GET` | `/issues/:id` | Get single issue |
220
- | `POST` | `/issues/:id/transition` | Transition status (`new`→`wip`→`closed`) |
220
+ | `POST` | `/issues/:id/transition` | Transition status free-form: any known status → any other known status |
221
221
  | `POST` | `/issues/:id/assign` | Assign to a user |
222
+ | `POST` | `/issues/:id/assign-to-me` | Assign to the authenticated actor (derived from the admin gate, not client input) |
222
223
  | `POST` | `/issues/:id/resolve` | Run an action + close the issue |
224
+ | `POST` | `/issues/archive-sweep` | Move closed issues older than the configured cutoff to `archived` — **global admins only** (`403` otherwise) |
225
+ | `GET` | `/issues/assignees` | List all users assignable to issues (`{ user_id, label, avatarUrl }`, for the reassign picker) |
223
226
 
224
227
  Scoped admins only see issues within their assigned scopes. Global admins (`admin_system`) see all.
225
228
 
229
+ Statuses: `new` / `wip` / `on_hold` / `closed` / `archived`. `on_hold` renders as a parked lane after Closed on the board. Transitions are unrestricted (only an unknown target status is rejected); `→ wip` still auto-assigns the actor.
230
+
231
+ ### Drop-in `IssuesPanel` (v0.10.0, filter bar + owner picker updated in v0.11.0)
232
+
233
+ Mount the Issues triage board on its own, without the full `AdminApp` shell:
234
+
235
+ ```tsx
236
+ import { IssuesPanel } from 'hazo_admin/ui';
237
+
238
+ export default function AdminIssuesPage() {
239
+ return <IssuesPanel basePath="/api/admin" currentUserId={currentUserId} />;
240
+ }
241
+ ```
242
+
243
+ `basePath` defaults to `/api/admin`. The board has four columns (New → WIP → Closed → On Hold). Filter controls live in a horizontal top bar above the board — search inline, plus **Type**, **Severity**, and **Age** (`Under 1 day` / `1–7 days` / `7–30 days` / `Over 30 days`) as dropdown chips with active-count badges, a **Clear · N** button, and **Manage types**. Status isn't a facet here since it's already the board's columns.
244
+
245
+ Each card shows per-card **type** and **owner** controls. The owner control uses `UserPickerSelect` from `hazo_auth/client` (≥ 10.5.0) — avatar + name/email dropdown — populated from `GET {basePath}/issues/assignees` (now every user, not just resolvable admins); selecting one POSTs `{basePath}/issues/:id/assign`. A compact **Me** button next to it POSTs `{basePath}/issues/:id/assign-to-me` to self-claim.
246
+
247
+ The panel header also has an **Archive old** button that POSTs `{basePath}/issues/archive-sweep` (global admins only) and refreshes both boards.
248
+
226
249
  ### Archive job
227
250
 
228
- Register the built-in archive job to sweep old closed issues:
251
+ Register the built-in archive job to sweep old closed issues (default cutoff is now **1 month**, was 3):
229
252
 
230
253
  ```ts
231
254
  import { registerIssueArchiveJob, ADMIN_ISSUE_ARCHIVE_JOB_TYPE } from 'hazo_admin';
232
255
  import { createJobQueue } from 'hazo_jobs';
233
256
 
234
- registerIssueArchiveJob(jobQueue, { getHazoConnect, archiveAfterMonths: 3 });
257
+ registerIssueArchiveJob(jobQueue, { getHazoConnect, archiveAfterMonths: 1 });
235
258
  ```
236
259
 
260
+ The **Archive old** button in `IssuesPanel` covers the same sweep on demand, without needing the job scheduled.
261
+
237
262
  ### `createIssueStoreFromConnect(adapter)` — PostgREST-native store (v0.6.0)
238
263
 
239
264
  Use this factory when the consuming app connects via a PostgREST adapter (no direct SQL access). It builds the same `IssueStore` interface using `createCrudService` + `adapter.claimRows` so no raw SQL is issued.
@@ -251,6 +276,42 @@ const { issue, isNew } = await store.createOrBumpIssue({ ... });
251
276
 
252
277
  **Async:** returns a `Promise<IssueStore>` (lazy `hazo_connect/server` import on the CRUD path).
253
278
 
279
+ ### Raising issues (v0.8.0)
280
+
281
+ `raiseIssue(connect, input, opts?)` is the single documented ingest protocol for
282
+ creating an issue from server-side code — a trusted-by-construction seam (no
283
+ HTTP ingest endpoint). See [design/issue_raising_protocol.md](./design/issue_raising_protocol.md)
284
+ for the full contract.
285
+
286
+ ```ts
287
+ import { raiseIssue } from 'hazo_admin';
288
+
289
+ const { issue, isNew } = await raiseIssue(connect, {
290
+ type: 'auth_permission', // must be a registered IssueTypeDef.typeKey
291
+ scope_id: orgId, // omit/null for a global (super-admin) issue
292
+ severity: 'high',
293
+ source: 'hazo_files',
294
+ payload: { requestedPermission: 'files.manage' },
295
+ });
296
+ ```
297
+
298
+ `scope_id` presence infers routing: present ⇒ scoped issue (scope admins),
299
+ omitted/`null` ⇒ global issue (super admins only). `title`/`summary` are
300
+ auto-derived from the type's `buildDescriptor(payload)` when not supplied.
301
+
302
+ Notify-on-raise is **optional and async** — only newly-created issues enqueue
303
+ a `hazo_admin.issue.notify` job, and only if `hazo_jobs` + `hazo_notify` are
304
+ installed and wired. Register the worker handler once at boot:
305
+
306
+ ```ts
307
+ import { registerIssueNotifyJob } from 'hazo_admin';
308
+
309
+ registerIssueNotifyJob(worker, { getHazoConnect });
310
+ ```
311
+
312
+ Without this wiring, issues still raise and show up in the Kanban — only the
313
+ notification is skipped.
314
+
254
315
  ---
255
316
 
256
317
  ## v0.2.0 Breaking changes
@@ -275,7 +336,7 @@ Consumers of this endpoint (e.g. `EnvMigrationPanel`) must read the `role` field
275
336
 
276
337
  | Import path | Contents | Server-safe |
277
338
  |---|---|---|
278
- | `hazo_admin` | `adminGate`, `withAdminGate`, `HAZO_ADMIN_PERMISSIONS`, `AdminGateResult`, `createIssueStore`, `createIssueStoreFromConnect`, `registerIssueType`, `getIssueType`, `listIssueTypes`, `loadIssueRoutingConfig`, `registerIssueArchiveJob`, `ADMIN_ISSUE_ARCHIVE_JOB_TYPE` | Server only |
339
+ | `hazo_admin` | `adminGate`, `withAdminGate`, `HAZO_ADMIN_PERMISSIONS`, `AdminGateResult`, `createIssueStore`, `createIssueStoreFromConnect`, `registerIssueType`, `getIssueType`, `listIssueTypes`, `loadIssueRoutingConfig`, `registerIssueArchiveJob`, `ADMIN_ISSUE_ARCHIVE_JOB_TYPE`, `raiseIssue`, `registerIssueNotifyJob`, `issueNotifyJobHandler`, `ADMIN_ISSUE_NOTIFY_JOB_TYPE`, `resolveGlobalAdmins`, `resolveScopeAdmins` | Server only |
279
340
  | `hazo_admin/client` | `HAZO_ADMIN_PERMISSIONS`, `registerIssueCardRenderer`, `getIssueCardRenderer`, `listIssueCardRenderers`, `DefaultIssueCard`, types | Yes |
280
341
  | `hazo_admin/api` | `createAdminPresetRoutes` | Server only |
281
342
  | `hazo_admin/jobs` | `ENV_MIGRATE_JOB_TYPE`, `envMigrateJobHandler` | Server only (no React) |
@@ -6,6 +6,8 @@
6
6
  npm install hazo_admin hazo_auth hazo_core hazo_ui
7
7
  ```
8
8
 
9
+ If mounting `IssuesPanel`, `hazo_auth` must be `>= 10.5.0` — the per-card owner control uses its `UserPickerSelect`.
10
+
9
11
  ## 2. Seed permission rows
10
12
 
11
13
  In your app's database migration, seed the permission strings that `hazo_admin` depends on:
@@ -45,3 +47,25 @@ export const GET = withAdminGate(
45
47
  ## 4. Configure (optional)
46
48
 
47
49
  Copy `config/hazo_admin_config.ini.sample` to `hazo_admin_config.ini` and adjust as needed.
50
+
51
+ ## 5. Apply migration 002 (issues: severity/source, nullable subject) — if upgrading
52
+
53
+ If you already applied `migrations/001_hazo_admin_issues.sql` (PostgreSQL), also apply
54
+ `migrations/002_add_issue_severity_source.sql` — it adds `severity`/`source` columns and
55
+ drops the `NOT NULL` constraint on `subject_user_id`. Fresh installs (PostgreSQL or
56
+ SQLite) get these via the updated `001` `CREATE TABLE` and don't need `002` at all.
57
+
58
+ ## 6. Wire issue-raised notifications (optional)
59
+
60
+ If you use `raiseIssue()` (see [design/issue_raising_protocol.md](./design/issue_raising_protocol.md))
61
+ and want admins notified when a new issue is raised, install `hazo_jobs` + `hazo_notify`
62
+ and register the notify job handler on your worker once at boot:
63
+
64
+ ```ts
65
+ import { registerIssueNotifyJob } from 'hazo_admin';
66
+
67
+ registerIssueNotifyJob(worker, { getHazoConnect });
68
+ ```
69
+
70
+ Without this, `raiseIssue()` still works and issues still show up in the Kanban — only
71
+ the notification is skipped.
@@ -20,7 +20,7 @@ export type AdminPresetRoutesConfig = {
20
20
  progressDir?: string;
21
21
  };
22
22
  issues?: {
23
- /** Months before closed issues are archived. Default 3. */
23
+ /** Months a closed issue must age before it is archived. Default 1. */
24
24
  archiveAfterMonths?: number;
25
25
  };
26
26
  };
@@ -40,6 +40,11 @@ export declare function createAdminPresetRoutes(manifest: AdminManifest, cfg: Ad
40
40
  path: string[];
41
41
  }>;
42
42
  }) => Promise<Response>;
43
+ PATCH: (request: Request, ctx: {
44
+ params: Promise<{
45
+ path: string[];
46
+ }>;
47
+ }) => Promise<Response>;
43
48
  DELETE: (request: Request, ctx: {
44
49
  params: Promise<{
45
50
  path: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAIrB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAEhE,MAAM,MAAM,uBAAuB,GAAG;IACpC;;;;OAIG;IACH,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE;QACL,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,wFAAwF;QACxF,OAAO,CAAC,EAAE,IAAI,GAAG,QAAQ,CAAC;KAC3B,CAAC;IACF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE;QACJ,gFAAgF;QAChF,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;IACF,MAAM,CAAC,EAAE;QACP,2DAA2D;QAC3D,kBAAkB,CAAC,EAAE,MAAM,CAAC;KAC7B,CAAC;CACH,CAAC;AAuBF,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,EAAE,uBAAuB;mBAmiBlE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;oBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;mBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;sBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;EAuGnG;AAGD,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,uBAAuB,EACvB,kBAAkB,EAClB,kBAAkB,EAClB,eAAe,GAChB,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAIrB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAKhE,MAAM,MAAM,uBAAuB,GAAG;IACpC;;;;OAIG;IACH,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE;QACL,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,wFAAwF;QACxF,OAAO,CAAC,EAAE,IAAI,GAAG,QAAQ,CAAC;KAC3B,CAAC;IACF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE;QACJ,gFAAgF;QAChF,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;IACF,MAAM,CAAC,EAAE;QACP,uEAAuE;QACvE,kBAAkB,CAAC,EAAE,MAAM,CAAC;KAC7B,CAAC;CACH,CAAC;AAuBF,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,EAAE,uBAAuB;mBAosBlE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;oBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;mBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;qBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;sBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;EAgHnG;AAGD,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,uBAAuB,EACvB,kBAAkB,EAClB,kBAAkB,EAClB,eAAe,GAChB,MAAM,0BAA0B,CAAC"}
package/dist/api/index.js CHANGED
@@ -2,6 +2,8 @@ import 'server-only';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
  import { withAdminGate } from '../lib/admin_gate.server.js';
5
+ /** Permission that grants global (cross-scope) admin access to the issues queue. */
6
+ const GLOBAL_ADMIN_PERMISSION = 'hazo_org_global_admin';
5
7
  function notFound() {
6
8
  return new Response(JSON.stringify({ error: { reason: 'not_found' } }), {
7
9
  status: 404,
@@ -43,6 +45,7 @@ export function createAdminPresetRoutes(manifest, cfg) {
43
45
  const feedbackPermission = feedbackSection?.permission ?? 'admin.feedback.review';
44
46
  const issuesSection = manifest.sections.find((s) => s.kind === 'issues');
45
47
  const issuesPermission = issuesSection?.permission ?? 'admin_issue_triage';
48
+ const issuesManagePermission = issuesSection?.managePermission ?? issuesPermission;
46
49
  async function handleJobs(request, method, segments) {
47
50
  const jobsMod = await import('hazo_jobs/server').catch(() => null);
48
51
  if (!jobsMod)
@@ -384,7 +387,7 @@ export function createAdminPresetRoutes(manifest, cfg) {
384
387
  // Derive actor identity from the authenticated gate result (never from the client).
385
388
  const actorUserId = gateResult?.user?.id ?? gateResult?.user?.user_id ?? '';
386
389
  // Derive admin scope from the gate result — never trust client-supplied params.
387
- const isGlobalAdmin = (gateResult?.permissions ?? []).includes('admin_system');
390
+ const isGlobalAdmin = (gateResult?.permissions ?? []).includes(GLOBAL_ADMIN_PERMISSION);
388
391
  let adminScopeIds = [];
389
392
  if (!isGlobalAdmin) {
390
393
  // Try to get user scopes from hazo_auth.
@@ -426,7 +429,7 @@ export function createAdminPresetRoutes(manifest, cfg) {
426
429
  const status = url.searchParams.get('status');
427
430
  const assignedTo = url.searchParams.get('assignedTo');
428
431
  const type = url.searchParams.get('type');
429
- const statusFilter = status ? status.split(',') : ['new', 'wip', 'closed'];
432
+ const statusFilter = status ? status.split(',') : ['new', 'wip', 'on_hold', 'closed'];
430
433
  const issues = await store.listIssues({
431
434
  adminScopeIds: isGlobalAdmin ? undefined : adminScopeIds,
432
435
  isGlobalAdmin,
@@ -436,6 +439,27 @@ export function createAdminPresetRoutes(manifest, cfg) {
436
439
  });
437
440
  return Response.json({ issues });
438
441
  }
442
+ // GET /issues/assignees — every user, for the reassign picker. Each row
443
+ // carries the display name and profile picture URL so the picker can render
444
+ // an avatar + username (falls back to tinted initials when no picture).
445
+ if (method === 'GET' && segments.length === 1 && segments[0] === 'assignees') {
446
+ let assignees = [];
447
+ try {
448
+ const { createCrudService } = await import('hazo_connect/server');
449
+ const users = await createCrudService(connect, 'hazo_users').list();
450
+ assignees = (Array.isArray(users) ? users : [])
451
+ .map((u) => ({
452
+ user_id: u.id,
453
+ label: (u.name || u.email_address || u.id),
454
+ avatarUrl: u.profile_picture_url ?? null,
455
+ }))
456
+ .filter((a) => Boolean(a.user_id));
457
+ }
458
+ catch {
459
+ return Response.json({ error: { reason: 'assignee_lookup_failed' } }, { status: 500 });
460
+ }
461
+ return Response.json({ assignees });
462
+ }
439
463
  // GET /issues/:id
440
464
  if (method === 'GET' && segments.length === 1) {
441
465
  const issue = await store.getIssue(segments[0]);
@@ -476,6 +500,34 @@ export function createAdminPresetRoutes(manifest, cfg) {
476
500
  const updated = await store.setAssignee(segments[0], body.userId ?? null);
477
501
  return Response.json({ issue: updated });
478
502
  }
503
+ // POST /issues/:id/assign-to-me — assign to the authenticated actor.
504
+ // The actor is derived from the gate, never the client, so no identity is
505
+ // needed on the client to "grab" an issue.
506
+ if (method === 'POST' && segments.length === 2 && segments[1] === 'assign-to-me') {
507
+ const issue = await store.getIssue(segments[0]);
508
+ if (!issue)
509
+ return notFound();
510
+ if (!canAccessIssue(issue))
511
+ return forbidden();
512
+ if (!actorUserId) {
513
+ return Response.json({ error: { reason: 'no_actor_identity' } }, { status: 403 });
514
+ }
515
+ const updated = await store.setAssignee(segments[0], actorUserId);
516
+ return Response.json({ issue: updated });
517
+ }
518
+ // POST /issues/archive-sweep — move closed issues older than the configured
519
+ // cutoff to `archived`. archiveClosedOlderThan is not scope-filtered, so this
520
+ // is restricted to global admins to avoid a scoped admin archiving every
521
+ // scope's issues.
522
+ if (method === 'POST' && segments.length === 1 && segments[0] === 'archive-sweep') {
523
+ if (!isGlobalAdmin)
524
+ return forbidden();
525
+ const months = cfg.issues?.archiveAfterMonths ?? 1;
526
+ const cutoff = new Date();
527
+ cutoff.setMonth(cutoff.getMonth() - months);
528
+ const archivedCount = await store.archiveClosedOlderThan(cutoff);
529
+ return Response.json({ archivedCount, cutoff: cutoff.toISOString() });
530
+ }
479
531
  // POST /issues/:id/resolve
480
532
  if (method === 'POST' && segments.length === 2 && segments[1] === 'resolve') {
481
533
  const body = await request.json().catch(() => ({}));
@@ -492,6 +544,9 @@ export function createAdminPresetRoutes(manifest, cfg) {
492
544
  if (!typeDef) {
493
545
  return Response.json({ error: { reason: `Unknown issue type: ${issue.type}` } }, { status: 422 });
494
546
  }
547
+ if (!typeDef.actions || typeDef.actions.length === 0) {
548
+ return Response.json({ error: { reason: `Issue type '${issue.type}' has no resolution actions` } }, { status: 422 });
549
+ }
495
550
  const action = typeDef.actions.find((a) => a.key === actionKey);
496
551
  if (!action) {
497
552
  return Response.json({ error: { reason: `Unknown action: ${actionKey}` } }, { status: 422 });
@@ -542,6 +597,110 @@ export function createAdminPresetRoutes(manifest, cfg) {
542
597
  return Response.json({ error: { reason: err.message } }, { status: 500 });
543
598
  }
544
599
  }
600
+ // POST /issues/:id/type — reclassify an issue's type
601
+ if (method === 'POST' && segments.length === 2 && segments[1] === 'type') {
602
+ const body = await request.json().catch(() => ({}));
603
+ const { type } = body;
604
+ if (!type) {
605
+ return Response.json({ error: { reason: 'type required' } }, { status: 400 });
606
+ }
607
+ const issue = await store.getIssue(segments[0]);
608
+ if (!issue)
609
+ return notFound();
610
+ if (!canAccessIssue(issue))
611
+ return forbidden();
612
+ const updated = await store.setType(segments[0], type);
613
+ return Response.json({ issue: updated });
614
+ }
615
+ return notFound();
616
+ }
617
+ async function handleIssueTypes(request, method, segments, gateResult) {
618
+ const { createIssueTypeCatalogStore } = await import('../issues/index.js');
619
+ const connect = await cfg.getHazoConnect();
620
+ const store = createIssueTypeCatalogStore(connect);
621
+ // Derive actor identity from the authenticated gate result (never from the client).
622
+ const actorUserId = gateResult?.user?.id ?? gateResult?.user?.user_id ?? '';
623
+ // Derive admin scope from the gate result — never trust client-supplied params.
624
+ const isGlobalAdmin = (gateResult?.permissions ?? []).includes(GLOBAL_ADMIN_PERMISSION);
625
+ let adminScopeIds = [];
626
+ if (!isGlobalAdmin) {
627
+ // Try to get user scopes from hazo_auth.
628
+ const authLib = await import('hazo_auth/server-lib').catch(() => null);
629
+ if (authLib?.get_user_scope_assignments) {
630
+ const scopeAssignments = await authLib.get_user_scope_assignments(actorUserId, connect).catch(() => []);
631
+ adminScopeIds = scopeAssignments.map((a) => a.scope_id).filter(Boolean);
632
+ // Also include descendants if the helper is available.
633
+ if (authLib.get_scope_descendants && adminScopeIds.length > 0) {
634
+ const descendants = await authLib.get_scope_descendants(adminScopeIds, connect).catch(() => []);
635
+ adminScopeIds = [...new Set([...adminScopeIds, ...descendants.map((d) => d.id ?? d.scope_id)])];
636
+ }
637
+ }
638
+ // If hazo_auth not available OR user has no scope assignments → adminScopeIds stays [].
639
+ }
640
+ /** Returns a 403 forbidden response. */
641
+ function forbidden() {
642
+ return new Response(JSON.stringify({ error: { reason: 'forbidden' } }), {
643
+ status: 403,
644
+ headers: { 'Content-Type': 'application/json' },
645
+ });
646
+ }
647
+ // GET /issue-types — list catalog
648
+ if (method === 'GET' && segments.length === 0) {
649
+ const types = await store.listTypes({ scopeIds: isGlobalAdmin ? undefined : adminScopeIds, isGlobalAdmin });
650
+ return Response.json({ issueTypes: types });
651
+ }
652
+ // All non-GET routes require the manage permission.
653
+ if (!(gateResult?.permissions ?? []).includes(issuesManagePermission))
654
+ return forbidden();
655
+ /**
656
+ * A scoped admin may only touch catalog rows that live inside one of their
657
+ * scopes. Global (scope_id null) and cross-scope rows are reserved for
658
+ * global admins. Prevents scope-bypass on catalog writes.
659
+ */
660
+ function canManageScope(scopeId) {
661
+ if (isGlobalAdmin)
662
+ return true;
663
+ return !!scopeId && adminScopeIds.includes(scopeId);
664
+ }
665
+ // POST /issue-types — create a catalog entry
666
+ if (method === 'POST' && segments.length === 0) {
667
+ const body = await request.json().catch(() => ({}));
668
+ const { type_key, label, color, description, scope_id, sort_order } = body;
669
+ if (!type_key || !label) {
670
+ return Response.json({ error: { reason: 'type_key and label required' } }, { status: 400 });
671
+ }
672
+ // Scoped admins can only create rows within their own scope (never global/cross-scope).
673
+ if (!canManageScope(scope_id))
674
+ return forbidden();
675
+ const created = await store.createType({ type_key, label, color, description, scope_id, sort_order });
676
+ return Response.json({ issueType: created });
677
+ }
678
+ // PATCH /issue-types/:key — update a catalog entry
679
+ if (method === 'PATCH' && segments.length === 1) {
680
+ const existing = await store.getType(segments[0]);
681
+ if (!existing)
682
+ return notFound();
683
+ if (!canManageScope(existing.scope_id))
684
+ return forbidden();
685
+ const patch = await request.json().catch(() => ({}));
686
+ try {
687
+ const updated = await store.updateType(segments[0], patch);
688
+ return Response.json({ issueType: updated });
689
+ }
690
+ catch (err) {
691
+ return Response.json({ error: { reason: err.message } }, { status: 404 });
692
+ }
693
+ }
694
+ // DELETE /issue-types/:key — remove a catalog entry (non-destructive to issues)
695
+ if (method === 'DELETE' && segments.length === 1) {
696
+ const existing = await store.getType(segments[0]);
697
+ if (!existing)
698
+ return notFound();
699
+ if (!canManageScope(existing.scope_id))
700
+ return forbidden();
701
+ await store.deleteType(segments[0]);
702
+ return Response.json({ ok: true });
703
+ }
545
704
  return notFound();
546
705
  }
547
706
  function makeHandler(method) {
@@ -593,6 +752,10 @@ export function createAdminPresetRoutes(manifest, cfg) {
593
752
  const gated = withAdminGate((_req, _gate) => handleIssues(_req, method, rest, _gate), { required_permissions: [issuesPermission] });
594
753
  return gated(request);
595
754
  }
755
+ if (group === 'issue-types') {
756
+ const gated = withAdminGate((_req, _gate) => handleIssueTypes(_req, method, rest, _gate), { required_permissions: [issuesPermission] });
757
+ return gated(request);
758
+ }
596
759
  return notFound();
597
760
  };
598
761
  }
@@ -600,6 +763,7 @@ export function createAdminPresetRoutes(manifest, cfg) {
600
763
  GET: makeHandler('GET'),
601
764
  POST: makeHandler('POST'),
602
765
  PUT: makeHandler('PUT'),
766
+ PATCH: makeHandler('PATCH'),
603
767
  DELETE: makeHandler('DELETE'),
604
768
  };
605
769
  }
@@ -0,0 +1,17 @@
1
+ import type { IssueCardData } from '../../issues/registry.client.js';
2
+ import type { IssueTypeCatalogRecord } from '../../issues/type_catalog.js';
3
+ export type BoardMode = 'status' | 'type';
4
+ export interface KanbanColumn {
5
+ key: string;
6
+ title: string;
7
+ color?: string | null;
8
+ }
9
+ export type KanbanItem = IssueCardData & {
10
+ columnKey: string;
11
+ };
12
+ export declare const ACTIVE_STATUS_COLUMNS: KanbanColumn[];
13
+ export declare const ARCHIVED_COLUMNS: KanbanColumn[];
14
+ export declare function statusColumns(tab: 'active' | 'archived'): KanbanColumn[];
15
+ export declare function typeColumns(catalog: Pick<IssueTypeCatalogRecord, 'type_key' | 'label' | 'color'>[], issues: IssueCardData[]): KanbanColumn[];
16
+ export declare function toKanbanItem(issue: IssueCardData, boardMode: BoardMode): KanbanItem;
17
+ //# sourceMappingURL=board_columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"board_columns.d.ts","sourceRoot":"","sources":["../../../src/components/issues_panel/board_columns.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAErE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAE3E,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE1C,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,MAAM,UAAU,GAAG,aAAa,GAAG;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/D,eAAO,MAAM,qBAAqB,EAAE,YAAY,EAK/C,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,YAAY,EAA6C,CAAC;AAGzF,wBAAgB,aAAa,CAAC,GAAG,EAAE,QAAQ,GAAG,UAAU,GAAG,YAAY,EAAE,CAExE;AAQD,wBAAgB,WAAW,CACzB,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,UAAU,GAAG,OAAO,GAAG,OAAO,CAAC,EAAE,EACvE,MAAM,EAAE,aAAa,EAAE,GACtB,YAAY,EAAE,CAahB;AAID,wBAAgB,YAAY,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,SAAS,GAAG,UAAU,CAEnF"}
@@ -0,0 +1,37 @@
1
+ export const ACTIVE_STATUS_COLUMNS = [
2
+ { key: 'new', title: 'New' },
3
+ { key: 'wip', title: 'WIP' },
4
+ { key: 'closed', title: 'Closed' },
5
+ { key: 'on_hold', title: 'On Hold' },
6
+ ];
7
+ export const ARCHIVED_COLUMNS = [{ key: 'archived', title: 'Archived' }];
8
+ // Status columns for a given tab.
9
+ export function statusColumns(tab) {
10
+ return tab === 'active' ? ACTIVE_STATUS_COLUMNS : ARCHIVED_COLUMNS;
11
+ }
12
+ // Type columns = catalog rows (ordered) UNION distinct types on the loaded issues.
13
+ // Catalog rows come first (key=type_key, title=label, color), in the order they
14
+ // arrive (already sorted by sort_order from the API). Any issue.type not already
15
+ // a column key is appended as its own column, titled by the raw type string, with
16
+ // color null, in first-seen order. Dedupe so a type that is both catalogued and
17
+ // in-use appears once.
18
+ export function typeColumns(catalog, issues) {
19
+ const columns = catalog.map((entry) => ({
20
+ key: entry.type_key,
21
+ title: entry.label,
22
+ color: entry.color ?? null,
23
+ }));
24
+ const seen = new Set(columns.map((c) => c.key));
25
+ for (const issue of issues) {
26
+ if (seen.has(issue.type))
27
+ continue;
28
+ seen.add(issue.type);
29
+ columns.push({ key: issue.type, title: issue.type, color: null });
30
+ }
31
+ return columns;
32
+ }
33
+ // Map an issue to a KanbanItem for the given board mode: columnKey = issue.status
34
+ // in status mode, issue.type in type mode.
35
+ export function toKanbanItem(issue, boardMode) {
36
+ return { ...issue, columnKey: boardMode === 'status' ? issue.status : issue.type };
37
+ }
@@ -0,0 +1,17 @@
1
+ import type { IssueCardData } from '../../issues/registry.client.js';
2
+ export interface Assignee {
3
+ user_id: string;
4
+ label: string;
5
+ /** Profile picture URL from the hazo_auth profile; null → initials fallback. */
6
+ avatarUrl?: string | null;
7
+ }
8
+ export interface CardAssigneeControlProps {
9
+ issue: IssueCardData;
10
+ basePath: string;
11
+ assignees: Assignee[];
12
+ onChanged?: (issueId: string, userId: string | null) => void;
13
+ /** Claim the issue for the authenticated actor (server derives identity). */
14
+ onAssignToMe?: (issueId: string) => void | Promise<void>;
15
+ }
16
+ export declare function CardAssigneeControl({ issue, basePath, assignees, onChanged, onAssignToMe }: CardAssigneeControlProps): import("react").JSX.Element;
17
+ //# sourceMappingURL=card_assignee_control.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"card_assignee_control.d.ts","sourceRoot":"","sources":["../../../src/components/issues_panel/card_assignee_control.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAGrE,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,aAAa,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAC7D,6EAA6E;IAC7E,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1D;AAUD,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,EAAE,wBAAwB,+BAgEpH"}
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { UserPickerSelect } from 'hazo_auth/client';
5
+ import { LIGHT_THEME_VARS } from './ui_helpers.js';
6
+ function toPickable(a) {
7
+ return { user_id: a.user_id, name: a.label, profile_picture_url: a.avatarUrl ?? null };
8
+ }
9
+ // Reassign control under an issue card. The owner dropdown is hazo_auth's shared
10
+ // UserPickerSelect (avatar + username); the "Me" button claims the issue for the
11
+ // authenticated actor. The dropdown portals to <body>, so LIGHT_THEME_VARS is
12
+ // pinned on its content to keep it light under the host app's OS dark mode.
13
+ export function CardAssigneeControl({ issue, basePath, assignees, onChanged, onAssignToMe }) {
14
+ const [saving, setSaving] = useState(false);
15
+ const [claiming, setClaiming] = useState(false);
16
+ const users = assignees.map(toPickable);
17
+ // Surface a current assignee not among the candidates so the trigger isn't blank.
18
+ if (issue.assigned_to && !users.some((u) => u.user_id === issue.assigned_to)) {
19
+ users.push({ user_id: issue.assigned_to, name: issue.assigned_to, profile_picture_url: null });
20
+ }
21
+ const commitAssignee = async (userId) => {
22
+ if ((userId ?? null) === (issue.assigned_to ?? null))
23
+ return;
24
+ setSaving(true);
25
+ try {
26
+ const res = await fetch(`${basePath}/issues/${issue.id}/assign`, {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify({ userId }),
30
+ });
31
+ if (res.ok)
32
+ onChanged?.(issue.id, userId);
33
+ }
34
+ finally {
35
+ setSaving(false);
36
+ }
37
+ };
38
+ const claimToMe = async () => {
39
+ if (!onAssignToMe || claiming)
40
+ return;
41
+ setClaiming(true);
42
+ try {
43
+ await onAssignToMe(issue.id);
44
+ }
45
+ finally {
46
+ setClaiming(false);
47
+ }
48
+ };
49
+ const stop = (e) => e.stopPropagation();
50
+ return (_jsxs("div", { className: "mt-1.5 flex items-center gap-2", onPointerDown: stop, onMouseDown: stop, onClick: stop, children: [_jsx("span", { className: "w-14 shrink-0 text-[10px] font-medium uppercase tracking-wide text-slate-400", children: "Owner" }), _jsx(UserPickerSelect, { users: users, value: issue.assigned_to ?? null, onChange: commitAssignee, disabled: saving, size: "sm", "aria-label": "Issue owner", className: "flex-1 rounded-md border-slate-200", contentStyle: LIGHT_THEME_VARS, contentClassName: "border border-slate-200 bg-white text-slate-900 shadow-lg" }), onAssignToMe ? (_jsx("button", { type: "button", onClick: claimToMe, disabled: claiming || saving, title: "Assign this issue to me", className: "shrink-0 rounded-md border border-slate-200 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500 transition-colors hover:border-indigo-300 hover:bg-indigo-50 hover:text-indigo-700 disabled:cursor-not-allowed disabled:opacity-50", children: claiming ? '…' : 'Me' })) : null] }));
51
+ }
@@ -0,0 +1,18 @@
1
+ import type { IssueCardData } from '../../issues/registry.client.js';
2
+ import type { CatalogType } from './use_issue_types.js';
3
+ export interface CardTypeControlUi {
4
+ Select?: any;
5
+ SelectTrigger?: any;
6
+ SelectContent?: any;
7
+ SelectItem?: any;
8
+ SelectValue?: any;
9
+ }
10
+ export interface CardTypeControlProps {
11
+ issue: IssueCardData;
12
+ basePath: string;
13
+ catalog: CatalogType[];
14
+ ui: CardTypeControlUi | null;
15
+ onChanged?: (issueId: string, newType: string) => void;
16
+ }
17
+ export declare function CardTypeControl({ issue, basePath, catalog, ui, onChanged }: CardTypeControlProps): import("react").JSX.Element;
18
+ //# sourceMappingURL=card_type_control.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"card_type_control.d.ts","sourceRoot":"","sources":["../../../src/components/issues_panel/card_type_control.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAGxD,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,GAAG,CAAC;IACb,aAAa,CAAC,EAAE,GAAG,CAAC;IACpB,aAAa,CAAC,EAAE,GAAG,CAAC;IACpB,UAAU,CAAC,EAAE,GAAG,CAAC;IACjB,WAAW,CAAC,EAAE,GAAG,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,aAAa,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,EAAE,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAC7B,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACxD;AAKD,wBAAgB,eAAe,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,oBAAoB,+BA2FhG"}