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.
- package/CHANGE_LOG.md +60 -0
- package/README.md +65 -4
- package/SETUP_CHECKLIST.md +24 -0
- package/dist/api/index.d.ts +6 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +166 -2
- package/dist/components/issues_panel/board_columns.d.ts +17 -0
- package/dist/components/issues_panel/board_columns.d.ts.map +1 -0
- package/dist/components/issues_panel/board_columns.js +37 -0
- package/dist/components/issues_panel/card_assignee_control.d.ts +17 -0
- package/dist/components/issues_panel/card_assignee_control.d.ts.map +1 -0
- package/dist/components/issues_panel/card_assignee_control.js +51 -0
- package/dist/components/issues_panel/card_type_control.d.ts +18 -0
- package/dist/components/issues_panel/card_type_control.d.ts.map +1 -0
- package/dist/components/issues_panel/card_type_control.js +42 -0
- package/dist/components/issues_panel/facet_sidebar.d.ts +25 -0
- package/dist/components/issues_panel/facet_sidebar.d.ts.map +1 -0
- package/dist/components/issues_panel/facet_sidebar.js +72 -0
- package/dist/components/issues_panel/facet_topbar.d.ts +20 -0
- package/dist/components/issues_panel/facet_topbar.d.ts.map +1 -0
- package/dist/components/issues_panel/facet_topbar.js +42 -0
- package/dist/components/issues_panel/filter.d.ts +12 -0
- package/dist/components/issues_panel/filter.d.ts.map +1 -0
- package/dist/components/issues_panel/filter.js +41 -0
- package/dist/components/issues_panel/index.d.ts.map +1 -1
- package/dist/components/issues_panel/index.js +145 -43
- package/dist/components/issues_panel/manage_types_dialog.d.ts +28 -0
- package/dist/components/issues_panel/manage_types_dialog.d.ts.map +1 -0
- package/dist/components/issues_panel/manage_types_dialog.js +84 -0
- package/dist/components/issues_panel/ui_helpers.d.ts +21 -0
- package/dist/components/issues_panel/ui_helpers.d.ts.map +1 -0
- package/dist/components/issues_panel/ui_helpers.js +136 -0
- package/dist/components/issues_panel/use_issue_types.d.ts +30 -0
- package/dist/components/issues_panel/use_issue_types.d.ts.map +1 -0
- package/dist/components/issues_panel/use_issue_types.js +81 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.ui.d.ts +2 -0
- package/dist/index.ui.d.ts.map +1 -1
- package/dist/index.ui.js +1 -0
- package/dist/issues/archive_handler.d.ts +1 -1
- package/dist/issues/archive_handler.js +2 -2
- package/dist/issues/index.d.ts +6 -0
- package/dist/issues/index.d.ts.map +1 -1
- package/dist/issues/index.js +4 -0
- package/dist/issues/notify_handler.d.ts +26 -0
- package/dist/issues/notify_handler.d.ts.map +1 -0
- package/dist/issues/notify_handler.js +97 -0
- package/dist/issues/raise.d.ts +44 -0
- package/dist/issues/raise.d.ts.map +1 -0
- package/dist/issues/raise.js +77 -0
- package/dist/issues/recipients.d.ts +20 -0
- package/dist/issues/recipients.d.ts.map +1 -0
- package/dist/issues/recipients.js +61 -0
- package/dist/issues/registry.client.d.ts +1 -0
- package/dist/issues/registry.client.d.ts.map +1 -1
- package/dist/issues/registry.client.js +4 -1
- package/dist/issues/registry.d.ts +3 -3
- package/dist/issues/registry.d.ts.map +1 -1
- package/dist/issues/store.d.ts +10 -5
- package/dist/issues/store.d.ts.map +1 -1
- package/dist/issues/store.js +58 -28
- package/dist/issues/type_catalog.d.ts +48 -0
- package/dist/issues/type_catalog.d.ts.map +1 -0
- package/dist/issues/type_catalog.js +185 -0
- 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
|
|
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:
|
|
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) |
|
package/SETUP_CHECKLIST.md
CHANGED
|
@@ -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.
|
package/dist/api/index.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type AdminPresetRoutesConfig = {
|
|
|
20
20
|
progressDir?: string;
|
|
21
21
|
};
|
|
22
22
|
issues?: {
|
|
23
|
-
/** Months
|
|
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[];
|
package/dist/api/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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(
|
|
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"}
|