@cryptotaxi247 / CoPilot / commits / 1f8cfa4c

feat(incidents): case templates with tasks and timeline (#827)

* feat(incidents): phase 1 — case template schema + Pydantic models Adds the data model foundation for issue #792 (case templates with tasks and timeline). No behavior change yet — these tables are not referenced by any route or service in this commit. New SQLModels in app/incidents/models.py: - CaseTemplate: reusable investigation playbook scoped by customer_code (NULL = global) and source (NULL = any). Selection priority on case creation (Phase 3) is customer+source > customer > source > is_default. - CaseTemplateTask: definition rows on a template (title, description, guidelines, mandatory, order_index). - CaseTask: instance rows attached to a real Case. Snapshot-copied from CaseTemplateTask at template-application time; template_task_id is an informational soft link (editing a template does not mutate existing CaseTask rows). Custom analyst-added tasks have template_task_id NULL. Status field accepts TODO / DONE / NOT_NECESSARY (NOT_NECESSARY only valid when mandatory=False — enforced at service layer in Phase 3). - CaseEvent: append-only audit log of case mutations, used to power the timeline view. event_type and case_id are indexed for fast per-case timeline lookups. New Pydantic schemas in app/incidents/schema/case_templates.py covering Create/Update/Response variants for templates, template tasks, case tasks, and timeline events. Also includes: - CaseTaskStatus / CaseEventType enums - CaseCloseWarningResponse — payload returned when closing a case with incomplete mandatory tasks (soft-warning behavior, requires force=true to override). Wired in Phase 3. Decisions baked in (per design discussion on issue #792): - Soft warning on close (not hard block) - First-alert-wins template selection in v1 - Tasks are snapshots, not live references to template rows - Custom-task add available during investigation - Customer portal will be read-only on tasks and timeline (enforced in Phase 3 route guards and Phase 5 UI) - Template ownership scoped to admin/analyst (enforced in Phase 2 router) Next: run alembic autogenerate to produce the migration, then Phase 2 implements template CRUD routes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(case-templates): add tables for case templates and related events * fix(incidents): phase 1 — widen case template text columns to TEXT The autogenerated migration 6ba5c2887ec5 materialized several free-form text columns as VARCHAR(255) because alembic's type inference doesn't pick up SQLModel's bare sa_column=Text reliably. This follow-up non-destructive ALTER migration widens them so analysts can paste multi-paragraph guidelines and log snippets / command output without truncation. Affected columns (all in incident_management_case_template*, incident_management_case_task*): - description (template, template_task, task) - guidelines (template_task, task) - evidence_comment (task) — most critical since the issue spec calls out "include evidence like logs, commands output" for this field. Tables are empty (introduced in 6ba5c2887ec5) so no data at risk. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(incidents): phase 2 — case template CRUD API (admin/analyst) Builds the API surface for managing case templates and their tasks. The entire router is gated on the admin or analyst scope; customers (customer_user scope) cannot view or modify templates. New service layer (app/incidents/services/case_templates.py): - create_template, list_templates, get_template, update_template, delete_template - add_template_task, update_template_task, delete_template_task, reorder_template_tasks - _enforce_single_default helper: when a template is marked is_default, any other default template within the same (customer_code, source) scope is automatically demoted so selection in Phase 3 stays unambiguous. - delete_template / delete_template_task explicitly NULL out the template_task_id soft FK on existing CaseTask snapshots so deleting a template doesn't clobber audit history on real cases. - Partial updates use Pydantic's __fields_set__ so callers can clear optional fields (description, customer_code, source) back to NULL. New router (app/incidents/routes/case_templates.py): - GET /incidents/case_templates — list with optional customer_code + source filters and an include_global flag (default true) so an admin UI can ask "what templates apply to customer X?" and get both customer-specific and global rows in one shot. - POST /incidents/case_templates — create, optionally with initial tasks - GET / PATCH / DELETE /incidents/case_templates/{id} - POST /incidents/case_templates/{id}/tasks — add task - PATCH / DELETE /incidents/case_templates/tasks/{task_id} - POST /incidents/case_templates/{id}/tasks/reorder — drag-and-drop reorder support for the future template editor UI. Wired into app/routers/incidents.py so the routes appear under the existing /api prefix alongside the rest of the incidents surface. Phase 3 will hook these templates into case creation (create_case_from_alert + manual create_case) and add the case-side task lifecycle endpoints with the soft-warning close behavior. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(incidents): phase 3 — case template integration + task lifecycle Wires case templates into case creation, exposes the case-side task lifecycle endpoints, and gates close-case on a soft warning when mandatory tasks are incomplete (per design discussion on issue #792). New service module (app/incidents/services/case_tasks.py): - pick_template_for_case(customer_code, source) — selection priority customer+source > customer-only > source-only > global default. Each step short-circuits on first match; ties within a step are broken by is_default desc, then created_at desc. - apply_template_to_case(case_id, template_id, actor) — snapshot-copies every CaseTemplateTask into a new CaseTask row. Snapshot semantics: editing the template later does NOT mutate already-applied tasks. Supports commit=False so it composes with the case-creation transaction. - auto_apply_template_for_new_case() — convenience wrapper used during create_case_from_alert (first-alert-wins source hint). - list_case_tasks / add_case_task / update_case_task / delete_case_task with NOT_NECESSARY-on-mandatory rejection and automatic completed_by/completed_at maintenance based on status transitions. - get_incomplete_mandatory_tasks() + build_close_warning_response() for the soft-warning gate. Integration into existing case creation flows (app/incidents/services/db_operations.py): - create_case_from_alert now accepts actor and template_id kwargs. When template_id is omitted, pick_template_for_case is called with alert.customer_code and alert.source. Template application happens before the commit so the case + initial tasks land atomically. - create_case (manual path) accepts the same kwargs. Without an alert source there's no auto-selection, but a template_id can be supplied explicitly. Analysts can also apply a template later via the manual-apply endpoint below. New routes on app/incidents/routes/db_operations.py: - GET /incidents/db_operations/case/{case_id}/tasks Read-only for customer_user; visible to admin/analyst as well. Customer access enforced via the existing customer_access_handler. - POST /incidents/db_operations/case/{case_id}/tasks Add a custom task mid-investigation. Admin/analyst only. - PATCH /incidents/db_operations/case/tasks/{task_id} Update status (TODO/DONE/NOT_NECESSARY) and/or evidence_comment. Admin/analyst only. NOT_NECESSARY rejected for mandatory tasks. - DELETE /incidents/db_operations/case/tasks/{task_id} Admin/analyst only. Allowed against template-derived tasks too. - POST /incidents/db_operations/case/{case_id}/apply-template/{template_id} Manual post-creation template application. Adds to existing tasks rather than replacing — analysts can layer multiple templates over an investigation (e.g., a Wazuh template plus an EDR template). Admin/analyst only. - /incidents/db_operations/case/from-alert and /case/create now accept an optional ?template_id= query param to override auto-selection. Soft-warning gate in PUT /incidents/db_operations/case/status: When closing a case, the route now calls get_incomplete_mandatory_tasks() and returns a CaseCloseWarningResponse (success=false, requires_confirmation=true, list of incomplete tasks) when any mandatory task is not DONE. The case is NOT closed; the caller re-submits with ?force=true to override. Response model is relaxed to None so the close path can return either the normal CaseOutResponse or the warning payload. Customer portal scope: - Read access on tasks: customer_user is included on the GET endpoint alongside admin/analyst. - Write paths (POST custom task, PATCH status, DELETE, apply-template) are restricted to admin and analyst scopes via Security() guards on each route. Customer users will see the tasks read-only in the Phase 5 UI. No frontend changes in this phase — Phase 5 will surface the new endpoints as Tasks and Timeline tabs on CaseDetails.vue. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(incidents): phase 4 — case timeline (CaseEvent) audit + GET endpoint Adds the unified audit log that powers the case timeline view. Every meaningful case mutation now writes one row to incident_management_case_event with an event_type, actor, timestamp, and a typed JSON payload. New service module (app/incidents/services/case_events.py): - emit_case_event() — append-only writer. Failures are logged but never raised; an audit-write failure must not break the underlying mutation. - list_case_events() — paginated, most-recent-first reader for the timeline endpoint. - payload_* convenience constructors (status_change, assignment, escalation, alert_link, alert_links_bulk, comment, template_applied, task) keep payload shapes consistent across emit sites. Audit emits wired into: Service-layer (lives in services/case_tasks.py): - apply_template_to_case: TEMPLATE_APPLIED + per-snapshotted-task TASK_ADDED - add_case_task (custom analyst-added task): TASK_ADDED with source='custom' - update_case_task: TASK_STATUS_CHANGED on a real status transition (no-op events are suppressed); TASK_COMMENTED when an evidence_comment is set in the same call. Both can fire in one PATCH and surface as two distinct timeline rows. Route-layer (lives in routes/db_operations.py — actor naturally available via current_user.username): - POST /case/create -> CASE_CREATED (source='manual') - POST /case/from-alert -> CASE_CREATED (source='from_alert') + ALERT_LINKED for the originating alert. template_applied / task_added come from the service layer (no double-emit). - POST /case/alert-link -> ALERT_LINKED - POST /case/alert-links -> single aggregated ALERT_LINKED carrying the list of alert_ids (avoids 50-row blasts for bulk attaches) - POST /case/alert-unlink -> ALERT_UNLINKED - PUT /case/status -> CASE_STATUS_CHANGED with from/to and a forced=true flag when the soft-warning was bypassed via ?force=true - PUT /case/assigned-to -> CASE_ASSIGNED with from/to assignee - PUT /case/escalated -> CASE_ESCALATED - POST /case/comment -> COMMENT_ADDED with a 140-char snippet preview New endpoint: - GET /incidents/db_operations/case/{case_id}/timeline Returns paginated CaseEventResponse rows ordered timestamp DESC. Visible to admin, analyst, and customer_user (read-only) so the customer portal in Phase 5 can show the timeline tab. Customer access is enforced via the existing customer_access_handler. No frontend yet — Phase 5 will surface this endpoint as the Timeline tab on CaseDetails.vue, alongside the Tasks tab. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(incidents): phase 4 — import CaseEventType in case_tasks service The Phase 4 audit emits added to apply_template_to_case, add_case_task, and update_case_task reference CaseEventType but the module-level import was missing, causing NameError at runtime when create_case_from_alert auto-applied a template. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(incidents): phase 5 — Tasks + Timeline tabs (analyst + customer portal) Surfaces the Phase 3/4 backend work in both Vue apps. Analysts get full read/write on Tasks; customers get a read-only view of Tasks and the case Timeline so they can see what the SOC team is doing on their cases. Analyst frontend (/frontend): - src/types/incidentManagement/caseTemplates.d.ts — TypeScript types for templates, template tasks, case tasks, case events, and the soft-warning response. - src/api/endpoints/incidentManagement/caseTemplates.ts — full API client covering template CRUD, template task CRUD + reorder, case task CRUD, manual apply-template, and timeline GET. Registered in the incidentManagement namespace index. - src/api/endpoints/incidentManagement/cases.ts — updateCaseStatus now accepts an optional `force` flag that maps to the backend's ?force=true query param used to bypass the soft warning. - src/components/incidentManagement/cases/CaseTasksList.vue — task UI with status dropdown (TODO/DONE/NOT_NECESSARY), evidence-comment textarea (auto-saved on blur), mandatory + custom badges, collapsible guidelines, completed-by/at footer, "Add task" modal, "Apply template" modal with a customer-scoped template picker, and delete-confirmation for custom tasks. - src/components/incidentManagement/cases/CaseTimelineFeed.vue — read-only chronological feed using n-timeline. Per-event-type icon, summary line, and detail renderer (comment snippet, alert id list). Falls back gracefully on unknown event_types so a future backend addition doesn't blank-render. - src/components/incidentManagement/cases/CaseDetails.vue — adds the Tasks and Timeline tab panes. Reads authStore.userRole and passes canEdit=false when the role is CustomerUser so any customer_user who lands on this view sees read-only. - src/components/incidentManagement/cases/CaseStatusSwitch.vue — intercepts the close-case path. When the backend returns requires_confirmation=true, shows a modal listing the incomplete mandatory tasks; "Close anyway" re-submits with force=true and records the override in the timeline. Cancel reverts the dropdown to the previous status without retriggering the watcher. Customer portal (/customer-portal): - src/types/caseTemplates.ts — read-only mirror of the relevant types. - src/api/endpoints/caseTemplates.ts — only GET endpoints exposed (getCaseTasks, getCaseTimeline). Backend write paths return 403 for customer_user scope, so this client intentionally omits the mutation surface entirely. - src/components/cases/CaseDetails/CaseTasks.vue — read-only task panel showing status, evidence notes, completion attribution, and guidelines. No interactive controls. - src/components/cases/CaseDetails/CaseTimeline.vue — read-only audit feed mirroring the analyst version but without write affordances. - src/components/cases/CaseDetails/CaseDetails.vue — wires the two new tab panes in. No backend changes in this phase. Phase 6 will add the template management view (CRUD UI) plus a CaseCreationForm template picker and the user docs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(incidents): phase 6 — template management UI + creation picker + docs Closes #792 Final phase: surfaces the case template Phase 1-2 backend work in the analyst UI so admins/analysts can author and curate templates without hand-rolling JSON, and gives the manual case-creation flow a way to opt into a specific template. Template management view (admin / analyst only): - src/views/incidentManagement/CaseTemplates.vue — page shell. - src/components/incidentManagement/caseTemplates/CaseTemplatesList.vue — n-data-table with name+default badge, scope (customer/source), task counts (total + mandatory), updated_at, edit/delete actions. Filters by customer_code / source / include_global, plus a free-text search across name/description/customer/source. Refresh button. Confirmation dialog on delete that explicitly notes CaseTask snapshots are preserved on real cases. - src/components/incidentManagement/caseTemplates/CaseTemplateEditor.vue — full template + tasks editor in a modal. * Metadata form (name, description, customer_code, source, is_default). * Inline task list editor: add task button, per-task title / description / guidelines / mandatory toggle, up/down reorder buttons, delete button. * Streaming saves: when editing a persisted template, task add/edit/delete/reorder hit the backend immediately so the user can iterate without an explicit "save tasks" round-trip. The metadata form still requires Save to commit. * Create flow batches: tasks are submitted alongside the CaseTemplateCreate payload so the backend creates them in one transaction. - src/router/index.ts — adds /incident-management/case-templates route with meta.roles=[Admin, Analyst]. Imports AuthUserRole alongside RouteRole. - src/app-layouts/common/Navbar/items.tsx — adds the "Case Templates" child item under Incident Management. Route-level role gating handles 403 if a customer_user clicks through. CaseCreationForm template picker: - src/components/incidentManagement/cases/CaseCreationForm.vue — optional template select below the customer field. Picker is customer-scoped (refetches when customer_code changes) and always includes global templates. Clears the selection when the customer changes so a stale ACME-only template can't accidentally apply to Customer B's case. - src/api/endpoints/incidentManagement/cases.ts — createCase and createCaseFromAlert now accept a params object so the picker can pass template_id as a query param without breaking existing callers. Docs: - docs/user/ui/incident-case-templates.md — full operator guide: scoping fields, matching priority (with first-alert-wins note), authoring workflow, snapshot semantics, soft-warning behavior, timeline coverage, three worked examples (Wazuh global default, customer-specific override, EDR addon), permissions matrix, and common gotchas. - mkdocs.yml — adds the new doc under Incident Management nav. That completes the case-template feature: backend tables + APIs (Phases 1-4), analyst Tasks/Timeline tabs + customer-portal read-only views (Phase 5), and the management UI + manual picker + docs (this phase). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(case-templates): add "How to use templates well" best-practices section Extends the case-templates operator doc with a practical guide on running the feature day-to-day. The original doc covered the mechanics; this commit covers the judgment calls operators face once they have the mechanics down. New sections in incident-case-templates.md: - Start with one global default per source, then layer (anti-overload guidance for teams just adopting templates). - Mandatory discipline: when a task should and shouldn't be marked mandatory, with an explicit anti-pattern callout for "everything is mandatory" and what that does to the soft-warning signal. - Use guidelines as the runbook quick-reference: what to put in the guidelines field, with a good vs bad example. - Evidence comments as the compliance trail: what belongs there and what doesn't (especially given customer-portal visibility). - Reorder for natural investigation flow: a 6-step sequencing pattern (triage → identify → investigate → decide → act → close). - The two-template pattern (source + capability) for cross-template workflows like Wazuh + EDR. - Custom-task adds as a feedback loop: how to use task_added events with source="custom" to drive template iteration. - Timeline as compliance + handoff tool: how to read the timeline as case narrative and what it answers for audits / shift changes. - Customer-portal as transparency tool: deliberate use of read-only visibility to communicate effort and substance, with explicit guidance on what to keep out of evidence comments. - Quarterly template review checklist (close-with-force rate, custom task patterns, NOT_NECESSARY hot-spots, wiki link rot, etc.). Also adds a Case Templates link to the Incident Management overview page so the template feature is discoverable from the menu landing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(deps): update frontend dependencies * style(incidents): format api calls and imports * style(ai-analyst): format metric tile * style(auth): remove unused nextTick import * style(snapshots): format schedule form * style(cases): format case management components * style(cases): format case template components * refactor(cases): split case task list components * refactor(cases): extract task creation form * refactor(cases): extract apply template form * refactor(cases): add debounced saving to task items * refactor(cases): update timeline feed UI and dates * refactor(cases): use CardEntity for task items * refactor(cases): simplify done task count * refactor(cases): use secondary style for templates * refactor(cases): refine status switch component * refactor(cases): refine task item layout * style(auth): add gap to totp form actions * refactor(cases): use CardEntity for task items * refactor(cases): refine timeline UI and dates * refactor(cases): refine template selection UI * refactor(cases): improve template list filters * refactor(cases): refine template list layout * refactor(cases): use JSX in template list * refactor(cases): refine template list UI * refactor(cases): use data table loading prop * refactor(cases): use selects in template editor * refactor(cases): show task saving indicators * precommit-fixes Co-authored-by: Copilot <copilot@github.com> * chore(version): update CURRENT_VERSION to 0.1.60 --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com> Co-authored-by: Copilot <copilot@github.com>

taylor_socfortress committed Apr 29, 2026 at 08:57 UTC 1f8cfa4c02a72bebc6c5676f0435b8fc6f12ba6e
45 files changed +5935 -408
backend/alembic/versions/03508fb56a48_widen_case_template_text_columns.py new
+72
@@ -0,0 +1,72 @@
1 +"""Widen case template text columns to TEXT
2 +
3 +Revision ID: 03508fb56a48
4 +Revises: 6ba5c2887ec5
5 +Create Date: 2026-04-26 19:30:00.000000
6 +
7 +The previous autogenerate (6ba5c2887ec5) materialized several free-form
8 +text columns as VARCHAR(255) because SQLModel's ``sa_column=Text`` form
9 +isn't picked up cleanly by alembic's type inference. This migration
10 +widens them to TEXT so analysts can paste multi-paragraph guidelines
11 +and log snippets / command output without truncation.
12 +
13 +Affected columns:
14 +- incident_management_case_template.description
15 +- incident_management_case_template_task.description
16 +- incident_management_case_template_task.guidelines
17 +- incident_management_case_task.description
18 +- incident_management_case_task.guidelines
19 +- incident_management_case_task.evidence_comment
20 +
21 +Non-destructive: the tables are introduced empty in 6ba5c2887ec5, so
22 +no data is at risk.
23 +"""
24 +
25 +from typing import Sequence
26 +from typing import Union
27 +
28 +import sqlalchemy as sa
29 +
30 +from alembic import op
31 +
32 +# revision identifiers, used by Alembic.
33 +revision: str = "03508fb56a48"
34 +down_revision: Union[str, None] = "6ba5c2887ec5"
35 +branch_labels: Union[str, Sequence[str], None] = None
36 +depends_on: Union[str, Sequence[str], None] = None
37 +
38 +
39 +# (table_name, column_name, nullable) — every column should be widened to TEXT.
40 +_WIDEN_COLUMNS = [
41 + ("incident_management_case_template", "description", True),
42 + ("incident_management_case_template_task", "description", True),
43 + ("incident_management_case_template_task", "guidelines", True),
44 + ("incident_management_case_task", "description", True),
45 + ("incident_management_case_task", "guidelines", True),
46 + ("incident_management_case_task", "evidence_comment", True),
47 +]
48 +
49 +
50 +def upgrade() -> None:
51 + for table, column, nullable in _WIDEN_COLUMNS:
52 + op.alter_column(
53 + table,
54 + column,
55 + existing_type=sa.String(length=255),
56 + type_=sa.Text(),
57 + existing_nullable=nullable,
58 + )
59 +
60 +
61 +def downgrade() -> None:
62 + # Reverses the widening. Will fail if any row contains a value longer
63 + # than 255 characters; that's intentional — losing data on downgrade
64 + # would be worse than refusing to run.
65 + for table, column, nullable in _WIDEN_COLUMNS:
66 + op.alter_column(
67 + table,
68 + column,
69 + existing_type=sa.Text(),
70 + type_=sa.String(length=255),
71 + existing_nullable=nullable,
72 + )
backend/alembic/versions/6ba5c2887ec5_add_case_template_tables.py new
+108
@@ -0,0 +1,108 @@
1 +"""Add case template tables
2 +
3 +Revision ID: 6ba5c2887ec5
4 +Revises: 51e33a247851
5 +Create Date: 2026-04-26 19:01:10.218459
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "6ba5c2887ec5"
17 +down_revision: Union[str, None] = "51e33a247851"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.create_table(
25 + "incident_management_case_template",
26 + sa.Column("id", sa.Integer(), nullable=False),
27 + sa.Column("name", sa.String(length=255), nullable=False),
28 + sa.Column("description", sa.String(length=255), nullable=True),
29 + sa.Column("customer_code", sa.String(length=50), nullable=True),
30 + sa.Column("source", sa.String(length=50), nullable=True),
31 + sa.Column("is_default", sa.Boolean(), nullable=False),
32 + sa.Column("created_by", sa.String(length=100), nullable=False),
33 + sa.Column("created_at", sa.DateTime(), nullable=False),
34 + sa.Column("updated_at", sa.DateTime(), nullable=False),
35 + sa.PrimaryKeyConstraint("id"),
36 + )
37 + op.create_table(
38 + "incident_management_case_event",
39 + sa.Column("payload", sa.JSON(), nullable=True),
40 + sa.Column("id", sa.Integer(), nullable=False),
41 + sa.Column("case_id", sa.Integer(), nullable=False),
42 + sa.Column("event_type", sa.String(length=64), nullable=False),
43 + sa.Column("actor", sa.String(length=100), nullable=False),
44 + sa.Column("timestamp", sa.DateTime(), nullable=False),
45 + sa.ForeignKeyConstraint(
46 + ["case_id"],
47 + ["incident_management_case.id"],
48 + ),
49 + sa.PrimaryKeyConstraint("id"),
50 + )
51 + op.create_index(op.f("ix_incident_management_case_event_case_id"), "incident_management_case_event", ["case_id"], unique=False)
52 + op.create_index(op.f("ix_incident_management_case_event_event_type"), "incident_management_case_event", ["event_type"], unique=False)
53 + op.create_index(op.f("ix_incident_management_case_event_timestamp"), "incident_management_case_event", ["timestamp"], unique=False)
54 + op.create_table(
55 + "incident_management_case_template_task",
56 + sa.Column("id", sa.Integer(), nullable=False),
57 + sa.Column("template_id", sa.Integer(), nullable=False),
58 + sa.Column("title", sa.String(length=500), nullable=False),
59 + sa.Column("description", sa.String(length=255), nullable=True),
60 + sa.Column("guidelines", sa.String(length=255), nullable=True),
61 + sa.Column("mandatory", sa.Boolean(), nullable=False),
62 + sa.Column("order_index", sa.Integer(), nullable=False),
63 + sa.ForeignKeyConstraint(
64 + ["template_id"],
65 + ["incident_management_case_template.id"],
66 + ),
67 + sa.PrimaryKeyConstraint("id"),
68 + )
69 + op.create_table(
70 + "incident_management_case_task",
71 + sa.Column("id", sa.Integer(), nullable=False),
72 + sa.Column("case_id", sa.Integer(), nullable=False),
73 + sa.Column("template_task_id", sa.Integer(), nullable=True),
74 + sa.Column("title", sa.String(length=500), nullable=False),
75 + sa.Column("description", sa.String(length=255), nullable=True),
76 + sa.Column("guidelines", sa.String(length=255), nullable=True),
77 + sa.Column("mandatory", sa.Boolean(), nullable=False),
78 + sa.Column("order_index", sa.Integer(), nullable=False),
79 + sa.Column("status", sa.String(length=50), nullable=False),
80 + sa.Column("evidence_comment", sa.String(length=255), nullable=True),
81 + sa.Column("completed_by", sa.String(length=100), nullable=True),
82 + sa.Column("completed_at", sa.DateTime(), nullable=True),
83 + sa.Column("created_by", sa.String(length=100), nullable=False),
84 + sa.Column("created_at", sa.DateTime(), nullable=False),
85 + sa.Column("updated_at", sa.DateTime(), nullable=False),
86 + sa.ForeignKeyConstraint(
87 + ["case_id"],
88 + ["incident_management_case.id"],
89 + ),
90 + sa.ForeignKeyConstraint(
91 + ["template_task_id"],
92 + ["incident_management_case_template_task.id"],
93 + ),
94 + sa.PrimaryKeyConstraint("id"),
95 + )
96 + # ### end Alembic commands ###
97 +
98 +
99 +def downgrade() -> None:
100 + # ### commands auto generated by Alembic - please adjust! ###
101 + op.drop_table("incident_management_case_task")
102 + op.drop_table("incident_management_case_template_task")
103 + op.drop_index(op.f("ix_incident_management_case_event_timestamp"), table_name="incident_management_case_event")
104 + op.drop_index(op.f("ix_incident_management_case_event_event_type"), table_name="incident_management_case_event")
105 + op.drop_index(op.f("ix_incident_management_case_event_case_id"), table_name="incident_management_case_event")
106 + op.drop_table("incident_management_case_event")
107 + op.drop_table("incident_management_case_template")
108 + # ### end Alembic commands ###
backend/app/incidents/models.py
+143
@@ -292,6 +292,149 @@ class ThresholdAlertMetadata(SQLModel, table=True):
292 alert: Alert = Relationship()
293
294
295 +class CaseTemplate(SQLModel, table=True):
296 + """
297 + Reusable investigation playbook applied to a Case at creation time.
298 +
299 + Templates are scoped via ``customer_code`` (NULL = global) and ``source``
300 + (NULL = any alert source). Selection priority on case creation is
301 + customer+source > customer > source > is_default. Templates carry a
302 + set of ``CaseTemplateTask`` rows that are snapshot-copied into
303 + ``CaseTask`` rows on the target case.
304 + """
305 +
306 + __tablename__ = "incident_management_case_template"
307 +
308 + id: Optional[int] = Field(default=None, primary_key=True)
309 + name: str = Field(max_length=255, nullable=False, description="Friendly template name")
310 + description: Optional[str] = Field(sa_column=Text, nullable=True, description="What this template is for")
311 + customer_code: Optional[str] = Field(
312 + max_length=50,
313 + nullable=True,
314 + description="Customer this template applies to. NULL = global / any customer.",
315 + )
316 + source: Optional[str] = Field(
317 + max_length=50,
318 + nullable=True,
319 + description="Alert source this template applies to (e.g., wazuh, velociraptor). NULL = any source.",
320 + )
321 + is_default: bool = Field(
322 + default=False,
323 + nullable=False,
324 + description="Default template for its (customer_code, source) scope. Used as the final fallback in selection.",
325 + )
326 + created_by: str = Field(max_length=100, nullable=False, description="User who created this template")
327 + created_at: datetime = Field(default_factory=datetime.utcnow)
328 + updated_at: datetime = Field(default_factory=datetime.utcnow)
329 +
330 + tasks: List["CaseTemplateTask"] = Relationship(back_populates="template")
331 +
332 +
333 +class CaseTemplateTask(SQLModel, table=True):
334 + """A predefined task on a CaseTemplate. Definition only — instances live in CaseTask."""
335 +
336 + __tablename__ = "incident_management_case_template_task"
337 +
338 + id: Optional[int] = Field(default=None, primary_key=True)
339 + template_id: int = Field(foreign_key="incident_management_case_template.id", nullable=False)
340 + title: str = Field(max_length=500, nullable=False)
341 + description: Optional[str] = Field(sa_column=Text, nullable=True)
342 + guidelines: Optional[str] = Field(
343 + sa_column=Text,
344 + nullable=True,
345 + description="Best practices / steps the analyst should follow when executing this task",
346 + )
347 + mandatory: bool = Field(
348 + default=False,
349 + nullable=False,
350 + description="If true, NOT_NECESSARY status is rejected and closing the case with this task incomplete triggers a soft warning.",
351 + )
352 + order_index: int = Field(default=0, nullable=False, description="Display order; lower = first")
353 +
354 + template: "CaseTemplate" = Relationship(back_populates="tasks")
355 +
356 +
357 +class CaseTask(SQLModel, table=True):
358 + """
359 + Instance of a task attached to a real Case.
360 +
361 + Rows are snapshots created by copying CaseTemplateTask fields when a
362 + template is applied. ``template_task_id`` is an informational soft link
363 + only — editing the source template does NOT mutate existing CaseTask rows.
364 + Custom tasks added by analysts during investigation have ``template_task_id``
365 + set to NULL.
366 + """
367 +
368 + __tablename__ = "incident_management_case_task"
369 +
370 + id: Optional[int] = Field(default=None, primary_key=True)
371 + case_id: int = Field(foreign_key="incident_management_case.id", nullable=False)
372 + template_task_id: Optional[int] = Field(
373 + default=None,
374 + foreign_key="incident_management_case_template_task.id",
375 + nullable=True,
376 + description="Soft link back to the source template task. NULL for custom-added tasks.",
377 + )
378 +
379 + # Snapshot of template task definition at the time of application.
380 + title: str = Field(max_length=500, nullable=False)
381 + description: Optional[str] = Field(sa_column=Text, nullable=True)
382 + guidelines: Optional[str] = Field(sa_column=Text, nullable=True)
383 + mandatory: bool = Field(default=False, nullable=False)
384 + order_index: int = Field(default=0, nullable=False)
385 +
386 + # Lifecycle.
387 + status: str = Field(
388 + default="TODO",
389 + max_length=50,
390 + nullable=False,
391 + description="One of TODO, DONE, NOT_NECESSARY (NOT_NECESSARY only valid when mandatory=False).",
392 + )
393 + evidence_comment: Optional[str] = Field(
394 + sa_column=Text,
395 + nullable=True,
396 + description="Free-form notes / evidence (logs, command output) attached when status changes.",
397 + )
398 + completed_by: Optional[str] = Field(max_length=100, nullable=True)
399 + completed_at: Optional[datetime] = Field(default=None, nullable=True)
400 +
401 + created_by: str = Field(max_length=100, nullable=False)
402 + created_at: datetime = Field(default_factory=datetime.utcnow)
403 + updated_at: datetime = Field(default_factory=datetime.utcnow)
404 +
405 +
406 +class CaseEvent(SQLModel, table=True):
407 + """
408 + Append-only audit log of mutations against a Case.
409 +
410 + Every case-level mutation (status change, alert link/unlink, assignment,
411 + template application, task add/status change/comment) emits one row.
412 + Used to power the case timeline view.
413 + """
414 +
415 + __tablename__ = "incident_management_case_event"
416 +
417 + id: Optional[int] = Field(default=None, primary_key=True)
418 + case_id: int = Field(foreign_key="incident_management_case.id", nullable=False, index=True)
419 + event_type: str = Field(
420 + max_length=64,
421 + nullable=False,
422 + index=True,
423 + description=(
424 + "One of: case_created, case_status_changed, case_assigned, case_escalated, "
425 + "alert_linked, alert_unlinked, comment_added, template_applied, "
426 + "task_added, task_status_changed, task_commented"
427 + ),
428 + )
429 + actor: str = Field(max_length=100, nullable=False, description="user_name that performed the action")
430 + timestamp: datetime = Field(default_factory=datetime.utcnow, index=True)
431 + payload: Optional[Dict] = Field(
432 + sa_column=Column(JSON),
433 + nullable=True,
434 + description="Event-type-specific JSON payload (e.g., from_status/to_status, alert_id, task_id).",
435 + )
436 +
437 +
438 class TagAccessSettings(SQLModel, table=True):
439 """Global settings for tag-based access control."""
440
backend/app/incidents/routes/case_templates.py new
+185
@@ -0,0 +1,185 @@
1 +"""
2 +Routes for case template + template-task management (issue #792, Phase 2).
3 +
4 +The entire router is gated on the ``admin`` or ``analyst`` scope — customers
5 +(``customer_user`` scope) cannot view or modify templates. Customer-facing
6 +visibility of *applied* tasks on cases is handled separately in Phase 3
7 +where the read-only path is exposed under the case detail endpoints.
8 +"""
9 +
10 +from typing import List
11 +from typing import Optional
12 +
13 +from fastapi import APIRouter
14 +from fastapi import Depends
15 +from fastapi import Query
16 +from fastapi import Security
17 +from sqlalchemy.ext.asyncio import AsyncSession
18 +
19 +from app.auth.utils import AuthHandler
20 +from app.db.db_session import get_db
21 +from app.incidents.schema.case_templates import CaseTemplateCreate
22 +from app.incidents.schema.case_templates import CaseTemplateListResponse
23 +from app.incidents.schema.case_templates import CaseTemplateOperationResponse
24 +from app.incidents.schema.case_templates import CaseTemplateTaskCreate
25 +from app.incidents.schema.case_templates import CaseTemplateTaskOperationResponse
26 +from app.incidents.schema.case_templates import CaseTemplateTaskUpdate
27 +from app.incidents.schema.case_templates import CaseTemplateUpdate
28 +from app.incidents.services import case_templates as service
29 +
30 +# Scope guard applied to every route on this router. Returns the username,
31 +# which we use as the audit actor for create operations.
32 +_require_admin_or_analyst = AuthHandler().require_any_scope("admin", "analyst")
33 +
34 +case_templates_router = APIRouter(
35 + dependencies=[Security(_require_admin_or_analyst)],
36 +)
37 +
38 +
39 +# ---------------------------------------------------------------------------
40 +# Template CRUD
41 +# ---------------------------------------------------------------------------
42 +
43 +
44 +@case_templates_router.get(
45 + "",
46 + response_model=CaseTemplateListResponse,
47 + description="List case templates. Admin/analyst only.",
48 +)
49 +async def list_case_templates(
50 + customer_code: Optional[str] = Query(
51 + None,
52 + description="Filter to templates for this customer plus global templates (unless include_global=False).",
53 + ),
54 + source: Optional[str] = Query(
55 + None,
56 + description="Filter to templates for this alert source plus source-agnostic templates (unless include_global=False).",
57 + ),
58 + include_global: bool = Query(
59 + True,
60 + description="When filtering by customer_code/source, also include rows where that field is NULL (i.e., global / any).",
61 + ),
62 + db: AsyncSession = Depends(get_db),
63 +) -> CaseTemplateListResponse:
64 + return await service.list_templates(
65 + session=db,
66 + customer_code=customer_code,
67 + source=source,
68 + include_global=include_global,
69 + )
70 +
71 +
72 +@case_templates_router.post(
73 + "",
74 + response_model=CaseTemplateOperationResponse,
75 + description="Create a new case template (with optional initial task list).",
76 +)
77 +async def create_case_template(
78 + request: CaseTemplateCreate,
79 + db: AsyncSession = Depends(get_db),
80 + actor: str = Security(_require_admin_or_analyst),
81 +) -> CaseTemplateOperationResponse:
82 + return await service.create_template(request=request, actor=actor, session=db)
83 +
84 +
85 +@case_templates_router.get(
86 + "/{template_id}",
87 + response_model=CaseTemplateOperationResponse,
88 + description="Fetch a single case template by ID, including its tasks.",
89 +)
90 +async def get_case_template(
91 + template_id: int,
92 + db: AsyncSession = Depends(get_db),
93 +) -> CaseTemplateOperationResponse:
94 + return await service.get_template(template_id=template_id, session=db)
95 +
96 +
97 +@case_templates_router.patch(
98 + "/{template_id}",
99 + response_model=CaseTemplateOperationResponse,
100 + description="Partial update of template metadata. Tasks are managed via the task endpoints.",
101 +)
102 +async def update_case_template(
103 + template_id: int,
104 + request: CaseTemplateUpdate,
105 + db: AsyncSession = Depends(get_db),
106 +) -> CaseTemplateOperationResponse:
107 + return await service.update_template(template_id=template_id, request=request, session=db)
108 +
109 +
110 +@case_templates_router.delete(
111 + "/{template_id}",
112 + response_model=CaseTemplateOperationResponse,
113 + description=(
114 + "Delete a template and its template tasks. Existing CaseTask snapshots on real cases "
115 + "are preserved (template_task_id is set to NULL on those rows so audit history survives)."
116 + ),
117 +)
118 +async def delete_case_template(
119 + template_id: int,
120 + db: AsyncSession = Depends(get_db),
121 +) -> CaseTemplateOperationResponse:
122 + return await service.delete_template(template_id=template_id, session=db)
123 +
124 +
125 +# ---------------------------------------------------------------------------
126 +# Template task CRUD
127 +# ---------------------------------------------------------------------------
128 +
129 +
130 +@case_templates_router.post(
131 + "/{template_id}/tasks",
132 + response_model=CaseTemplateTaskOperationResponse,
133 + description="Add a task to an existing template.",
134 +)
135 +async def add_case_template_task(
136 + template_id: int,
137 + request: CaseTemplateTaskCreate,
138 + db: AsyncSession = Depends(get_db),
139 +) -> CaseTemplateTaskOperationResponse:
140 + return await service.add_template_task(template_id=template_id, request=request, session=db)
141 +
142 +
143 +@case_templates_router.patch(
144 + "/tasks/{task_id}",
145 + response_model=CaseTemplateTaskOperationResponse,
146 + description="Partial update of a template task definition.",
147 +)
148 +async def update_case_template_task(
149 + task_id: int,
150 + request: CaseTemplateTaskUpdate,
151 + db: AsyncSession = Depends(get_db),
152 +) -> CaseTemplateTaskOperationResponse:
153 + return await service.update_template_task(task_id=task_id, request=request, session=db)
154 +
155 +
156 +@case_templates_router.delete(
157 + "/tasks/{task_id}",
158 + response_model=CaseTemplateTaskOperationResponse,
159 + description="Delete a template task. Existing CaseTask snapshots on real cases keep their data.",
160 +)
161 +async def delete_case_template_task(
162 + task_id: int,
163 + db: AsyncSession = Depends(get_db),
164 +) -> CaseTemplateTaskOperationResponse:
165 + return await service.delete_template_task(task_id=task_id, session=db)
166 +
167 +
168 +@case_templates_router.post(
169 + "/{template_id}/tasks/reorder",
170 + response_model=CaseTemplateOperationResponse,
171 + description=(
172 + "Reorder tasks within a template. Pass the full ordered list of task IDs; "
173 + "tasks not included keep their existing order_index value."
174 + ),
175 +)
176 +async def reorder_case_template_tasks(
177 + template_id: int,
178 + ordered_task_ids: List[int],
179 + db: AsyncSession = Depends(get_db),
180 +) -> CaseTemplateOperationResponse:
181 + return await service.reorder_template_tasks(
182 + template_id=template_id,
183 + ordered_task_ids=ordered_task_ids,
184 + session=db,
185 + )
backend/app/incidents/routes/db_operations.py
+418 -14
@@ -35,6 +35,8 @@ from app.incidents.models import CaseAlertLink
35 from app.incidents.models import CaseComment
36 from app.incidents.models import Comment
37 from app.incidents.models import FieldName
38 +from app.incidents.schema.case_templates import CaseTaskCreate
39 +from app.incidents.schema.case_templates import CaseTaskUpdate
40 from app.incidents.schema.db_operations import AITriggerResponse
41 from app.incidents.schema.db_operations import AlertContextCreate
42 from app.incidents.schema.db_operations import AlertContextResponse
@@ -660,7 +662,22 @@ async def create_case_comment_endpoint(
662 if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
663 raise HTTPException(status_code=403, detail=f"Access denied to case {comment.case_id} - insufficient customer permissions")
664
663 - return CaseCommentResponse(comment=await create_case_comment(comment, db), success=True, message="Case comment created successfully")
665 + created = await create_case_comment(comment, db)
666 +
667 + from app.incidents.schema.case_templates import CaseEventType
668 + from app.incidents.services.case_events import emit_case_event
669 + from app.incidents.services.case_events import payload_comment
670 +
671 + await emit_case_event(
672 + session=db,
673 + case_id=comment.case_id,
674 + event_type=CaseEventType.COMMENT_ADDED,
675 + actor=current_user.username,
676 + payload=payload_comment(comment_id=created.id, snippet=created.comment),
677 + commit=True,
678 + )
679 +
680 + return CaseCommentResponse(comment=created, success=True, message="Case comment created successfully")
681
682
683 @incidents_db_operations_router.put(
@@ -929,8 +946,31 @@ async def delete_alert_tag_endpoint(alert_tag: AlertTagDelete, db: AsyncSession
946 response_model=CaseResponse,
947 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
948 )
932 -async def create_case_endpoint(case: CaseCreate, db: AsyncSession = Depends(get_db)):
933 - return CaseResponse(case=await create_case(case, db), success=True, message="Case created successfully")
949 +async def create_case_endpoint(
950 + case: CaseCreate,
951 + template_id: Optional[int] = Query(
952 + None,
953 + description="Optional CaseTemplate id to apply on creation. Skips auto-selection (manual path has no alert source).",
954 + ),
955 + current_user: User = Depends(AuthHandler().get_current_user),
956 + db: AsyncSession = Depends(get_db),
957 +):
958 + created = await create_case(case, db, actor=current_user.username, template_id=template_id)
959 +
960 + # Phase 4 audit emit
961 + from app.incidents.schema.case_templates import CaseEventType
962 + from app.incidents.services.case_events import emit_case_event
963 +
964 + await emit_case_event(
965 + session=db,
966 + case_id=created.id,
967 + event_type=CaseEventType.CASE_CREATED,
968 + actor=current_user.username,
969 + payload={"source": "manual", "template_id": template_id},
970 + commit=True,
971 + )
972 +
973 + return CaseResponse(case=created, success=True, message="Case created successfully")
974
975
976 @incidents_db_operations_router.post(
@@ -938,9 +978,28 @@ async def create_case_endpoint(case: CaseCreate, db: AsyncSession = Depends(get_
978 response_model=CaseAlertLinkResponse,
979 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
980 )
941 -async def create_case_alert_link_endpoint(case_alert_link: CaseAlertLinkCreate, db: AsyncSession = Depends(get_db)):
981 +async def create_case_alert_link_endpoint(
982 + case_alert_link: CaseAlertLinkCreate,
983 + current_user: User = Depends(AuthHandler().get_current_user),
984 + db: AsyncSession = Depends(get_db),
985 +):
986 + link = await create_case_alert_link(case_alert_link, db)
987 +
988 + from app.incidents.schema.case_templates import CaseEventType
989 + from app.incidents.services.case_events import emit_case_event
990 + from app.incidents.services.case_events import payload_alert_link
991 +
992 + await emit_case_event(
993 + session=db,
994 + case_id=case_alert_link.case_id,
995 + event_type=CaseEventType.ALERT_LINKED,
996 + actor=current_user.username,
997 + payload=payload_alert_link(alert_id=case_alert_link.alert_id),
998 + commit=True,
999 + )
1000 +
1001 return CaseAlertLinkResponse(
943 - case_alert_link=await create_case_alert_link(case_alert_link, db),
1002 + case_alert_link=link,
1003 success=True,
1004 message="Case alert link created successfully",
1005 )
@@ -951,9 +1010,30 @@ async def create_case_alert_link_endpoint(case_alert_link: CaseAlertLinkCreate,
1010 response_model=CaseAlertLinksResponse,
1011 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1012 )
954 -async def create_case_alert_links_endpoint(case_alert_links: CaseAlertLinksCreate, db: AsyncSession = Depends(get_db)):
1013 +async def create_case_alert_links_endpoint(
1014 + case_alert_links: CaseAlertLinksCreate,
1015 + current_user: User = Depends(AuthHandler().get_current_user),
1016 + db: AsyncSession = Depends(get_db),
1017 +):
1018 + links = await create_case_alert_links_bulk(case_alert_links, db)
1019 +
1020 + from app.incidents.schema.case_templates import CaseEventType
1021 + from app.incidents.services.case_events import emit_case_event
1022 + from app.incidents.services.case_events import payload_alert_links_bulk
1023 +
1024 + # One aggregated event for the bulk operation rather than N individual
1025 + # ones — keeps the timeline readable when 50 alerts are bulk-attached.
1026 + await emit_case_event(
1027 + session=db,
1028 + case_id=case_alert_links.case_id,
1029 + event_type=CaseEventType.ALERT_LINKED,
1030 + actor=current_user.username,
1031 + payload=payload_alert_links_bulk(alert_ids=case_alert_links.alert_ids),
1032 + commit=True,
1033 + )
1034 +
1035 return CaseAlertLinksResponse(
956 - case_alert_links=await create_case_alert_links_bulk(case_alert_links, db),
1036 + case_alert_links=links,
1037 success=True,
1038 message="Case alert links created successfully",
1039 )
@@ -964,8 +1044,27 @@ async def create_case_alert_links_endpoint(case_alert_links: CaseAlertLinksCreat
1044 response_model=CaseAlertUnLinkResponse,
1045 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1046 )
967 -async def case_alert_unlink_endpoint(case_alert_link: CaseAlertUnLink, db: AsyncSession = Depends(get_db)):
968 - return await case_alert_unlink(case_alert_link, db)
1047 +async def case_alert_unlink_endpoint(
1048 + case_alert_link: CaseAlertUnLink,
1049 + current_user: User = Depends(AuthHandler().get_current_user),
1050 + db: AsyncSession = Depends(get_db),
1051 +):
1052 + response = await case_alert_unlink(case_alert_link, db)
1053 +
1054 + from app.incidents.schema.case_templates import CaseEventType
1055 + from app.incidents.services.case_events import emit_case_event
1056 + from app.incidents.services.case_events import payload_alert_link
1057 +
1058 + await emit_case_event(
1059 + session=db,
1060 + case_id=case_alert_link.case_id,
1061 + event_type=CaseEventType.ALERT_UNLINKED,
1062 + actor=current_user.username,
1063 + payload=payload_alert_link(alert_id=case_alert_link.alert_id),
1064 + commit=True,
1065 + )
1066 +
1067 + return response
1068
1069
1070 @incidents_db_operations_router.post(
@@ -973,12 +1072,57 @@ async def case_alert_unlink_endpoint(case_alert_link: CaseAlertUnLink, db: Async
1072 response_model=CaseAlertLinkResponse,
1073 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1074 )
976 -async def create_case_from_alert_endpoint(alert_id: CaseCreateFromAlert, db: AsyncSession = Depends(get_db)):
977 - case = await create_case_from_alert(alert_id.alert_id, db)
1075 +async def create_case_from_alert_endpoint(
1076 + alert_id: CaseCreateFromAlert,
1077 + template_id: Optional[int] = Query(
1078 + None,
1079 + description=(
1080 + "Optional CaseTemplate id to apply on creation. When omitted, the best matching "
1081 + "template is auto-selected from the alert's (customer_code, source). First-alert-wins "
1082 + "semantics: subsequent alerts linked to the case do not retrigger template selection."
1083 + ),
1084 + ),
1085 + current_user: User = Depends(AuthHandler().get_current_user),
1086 + db: AsyncSession = Depends(get_db),
1087 +):
1088 + case = await create_case_from_alert(
1089 + alert_id.alert_id,
1090 + db,
1091 + actor=current_user.username,
1092 + template_id=template_id,
1093 + )
1094 if case is None:
1095 return CaseResponse(case=None, success=False, message="Case not created")
1096 +
1097 + link = await create_case_alert_link(CaseAlertLinkCreate(case_id=case.id, alert_id=alert_id.alert_id), db)
1098 +
1099 + # Phase 4 audit emits: case_created + alert_linked (the originating
1100 + # alert is the first link). template_applied / task_added events are
1101 + # already emitted by the apply_template_to_case service inside
1102 + # create_case_from_alert, so we don't re-emit them here.
1103 + from app.incidents.schema.case_templates import CaseEventType
1104 + from app.incidents.services.case_events import emit_case_event
1105 + from app.incidents.services.case_events import payload_alert_link
1106 +
1107 + await emit_case_event(
1108 + session=db,
1109 + case_id=case.id,
1110 + event_type=CaseEventType.CASE_CREATED,
1111 + actor=current_user.username,
1112 + payload={"source": "from_alert", "alert_id": alert_id.alert_id, "template_id": template_id},
1113 + commit=False,
1114 + )
1115 + await emit_case_event(
1116 + session=db,
1117 + case_id=case.id,
1118 + event_type=CaseEventType.ALERT_LINKED,
1119 + actor=current_user.username,
1120 + payload=payload_alert_link(alert_id=alert_id.alert_id),
1121 + commit=True,
1122 + )
1123 +
1124 return CaseAlertLinkResponse(
981 - case_alert_link=await create_case_alert_link(CaseAlertLinkCreate(case_id=case.id, alert_id=alert_id.alert_id), db),
1125 + case_alert_link=link,
1126 success=True,
1127 message="Case created from alert successfully",
1128 )
@@ -1650,15 +1794,42 @@ async def list_cases_endpoint(
1794
1795 @incidents_db_operations_router.put(
1796 "/case/status",
1653 - response_model=CaseOutResponse,
1797 + # Response can be either CaseOutResponse (normal close) or
1798 + # CaseCloseWarningResponse (soft warning when mandatory tasks are
1799 + # incomplete). FastAPI doesn't model Union responses cleanly under
1800 + # Pydantic v1, so we drop response_model and document the contract
1801 + # in the docstring + responses dict.
1802 + response_model=None,
1803 + responses={
1804 + 200: {
1805 + "description": (
1806 + "Either the updated case or a soft-warning payload when closing a case with "
1807 + "incomplete mandatory tasks. See CaseCloseWarningResponse — re-submit with "
1808 + "?force=true to confirm."
1809 + ),
1810 + },
1811 + },
1812 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1813 )
1814 async def update_case_status_endpoint(
1815 case_status: UpdateCaseStatus,
1816 + force: bool = Query(
1817 + False,
1818 + description=(
1819 + "When closing a case, set force=true to bypass the soft warning that fires when "
1820 + "mandatory tasks are not all marked DONE. Has no effect for non-CLOSED transitions."
1821 + ),
1822 + ),
1823 current_user: User = Depends(AuthHandler().get_current_user),
1824 db: AsyncSession = Depends(get_db),
1825 ):
1661 - """Update case status with customer access validation and auto-update linked alerts"""
1826 + """Update case status with customer access validation and auto-update linked alerts.
1827 +
1828 + Phase 3 (issue #792) adds a soft-warning gate: closing a case with
1829 + incomplete mandatory tasks returns a CaseCloseWarningResponse and
1830 + does NOT actually close the case. The caller re-submits with
1831 + force=true to override.
1832 + """
1833 logger.info(f"Updating case {case_status.case_id} status to {case_status.status} for user: {current_user.username}")
1834
1835 # Get the case first to check customer access
@@ -1676,6 +1847,20 @@ async def update_case_status_endpoint(
1847
1848 logger.info(f"Case status transition: {old_status} -> {new_status_value}")
1849
1850 + # Phase 3 (issue #792) soft-warning gate: if the case is being closed
1851 + # and any mandatory CaseTask is not DONE, return a warning payload
1852 + # without performing the close. force=true overrides.
1853 + if new_status_value == "CLOSED" and old_status != "CLOSED" and not force:
1854 + from app.incidents.services.case_tasks import build_close_warning_response
1855 + from app.incidents.services.case_tasks import get_incomplete_mandatory_tasks
1856 +
1857 + incomplete = await get_incomplete_mandatory_tasks(case_status.case_id, db)
1858 + if incomplete:
1859 + logger.info(
1860 + f"Case {case_status.case_id} close blocked by soft warning: " f"{len(incomplete)} mandatory task(s) not DONE",
1861 + )
1862 + return build_close_warning_response(incomplete)
1863 +
1864 try:
1865 # Get all alert IDs linked to this case BEFORE updating
1866 result = await db.execute(select(CaseAlertLink.alert_id).where(CaseAlertLink.case_id == case_status.case_id))
@@ -1736,6 +1921,25 @@ async def update_case_status_endpoint(
1921 await db.rollback()
1922 raise HTTPException(status_code=500, detail=f"Failed to update case status: {str(e)}")
1923
1924 + # Phase 4 audit emit. Records the from/to status, plus a forced flag
1925 + # when the soft-warning was bypassed so it shows up in the timeline.
1926 + from app.incidents.schema.case_templates import CaseEventType as _ET
1927 + from app.incidents.services.case_events import emit_case_event as _emit
1928 + from app.incidents.services.case_events import payload_status_change as _psc
1929 +
1930 + await _emit(
1931 + session=db,
1932 + case_id=case_status.case_id,
1933 + event_type=_ET.CASE_STATUS_CHANGED,
1934 + actor=current_user.username,
1935 + payload=_psc(
1936 + from_status=old_status,
1937 + to_status=new_status_value,
1938 + forced=(force and new_status_value == "CLOSED"),
1939 + ),
1940 + commit=True,
1941 + )
1942 +
1943 # Re-fetch the case with full data structure
1944 updated_case = await get_case_by_id(case_status.case_id, db)
1945
@@ -1771,6 +1975,20 @@ async def update_case_escalated_endpoint(
1975 # Update the case escalated status
1976 await update_case_escalated(escalate_case.case_id, escalate_case.escalated, db)
1977
1978 + # Phase 4 audit emit
1979 + from app.incidents.schema.case_templates import CaseEventType
1980 + from app.incidents.services.case_events import emit_case_event
1981 + from app.incidents.services.case_events import payload_escalation
1982 +
1983 + await emit_case_event(
1984 + session=db,
1985 + case_id=escalate_case.case_id,
1986 + event_type=CaseEventType.CASE_ESCALATED,
1987 + actor=current_user.username,
1988 + payload=payload_escalation(escalated=escalate_case.escalated),
1989 + commit=True,
1990 + )
1991 +
1992 # Re-fetch the case with full data structure
1993 updated_case = await get_case_by_id(escalate_case.case_id, db)
1994 return CaseOutResponse(cases=[updated_case], success=True, message="Case escalated status updated successfully")
@@ -1801,9 +2019,26 @@ async def update_case_assigned_to_endpoint(
2019 if assigned_to.assigned_to not in user_names:
2020 raise HTTPException(status_code=400, detail="User does not exist")
2021
2022 + # Capture previous assignee BEFORE the mutation so the audit payload is accurate.
2023 + previous_assignee = case.assigned_to
2024 +
2025 # Update the case assigned_to
2026 await update_case_assigned_to(assigned_to.case_id, assigned_to.assigned_to, db)
2027
2028 + # Phase 4 audit emit
2029 + from app.incidents.schema.case_templates import CaseEventType
2030 + from app.incidents.services.case_events import emit_case_event
2031 + from app.incidents.services.case_events import payload_assignment
2032 +
2033 + await emit_case_event(
2034 + session=db,
2035 + case_id=assigned_to.case_id,
2036 + event_type=CaseEventType.CASE_ASSIGNED,
2037 + actor=current_user.username,
2038 + payload=payload_assignment(from_assignee=previous_assignee, to_assignee=assigned_to.assigned_to),
2039 + commit=True,
2040 + )
2041 +
2042 # Re-fetch the case with full data structure
2043 updated_case = await get_case_by_id(assigned_to.case_id, db)
2044 return CaseOutResponse(
@@ -2305,3 +2540,172 @@ async def upload_case_report_template_endpoint(
2540 async def delete_case_report_template_endpoint(file_name: str, db: AsyncSession = Depends(get_db)):
2541 await delete_report_template(file_name, db)
2542 return {"message": "File deleted successfully", "success": True}
2543 +
2544 +
2545 +# ============================================================================
2546 +# Case Tasks (Phase 3, issue #792)
2547 +#
2548 +# Customer portal users have read-only visibility on tasks (GET allowed),
2549 +# but creation, status updates, and deletion are restricted to admin/analyst.
2550 +# Customer access is enforced per-case using customer_access_handler.
2551 +# ============================================================================
2552 +
2553 +# Authorization handles imported lazily here to avoid a circular import: the
2554 +# case_tasks service imports from this services/db_operations module too.
2555 +_admin_analyst_dep = Security(AuthHandler().require_any_scope("admin", "analyst"))
2556 +_all_scopes_dep = Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))
2557 +
2558 +
2559 +async def _ensure_case_access(case_id: int, current_user: User, db: AsyncSession) -> None:
2560 + """Shared helper: 404 if case missing, 403 if user lacks customer access."""
2561 + case = await get_case_by_id(case_id, db)
2562 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
2563 + raise HTTPException(
2564 + status_code=403,
2565 + detail=f"Access denied to case {case_id} - insufficient customer permissions",
2566 + )
2567 +
2568 +
2569 +@incidents_db_operations_router.get(
2570 + "/case/{case_id}/tasks",
2571 + description="List CaseTask rows for a case. Visible to admin, analyst, and customer_user (read-only).",
2572 + dependencies=[_all_scopes_dep],
2573 +)
2574 +async def list_case_tasks_endpoint(
2575 + case_id: int,
2576 + current_user: User = Depends(AuthHandler().get_current_user),
2577 + db: AsyncSession = Depends(get_db),
2578 +):
2579 + from app.incidents.services.case_tasks import list_case_tasks
2580 +
2581 + await _ensure_case_access(case_id, current_user, db)
2582 + return await list_case_tasks(case_id, db)
2583 +
2584 +
2585 +@incidents_db_operations_router.post(
2586 + "/case/{case_id}/tasks",
2587 + description="Add a custom task to a case during investigation. Admin/analyst only.",
2588 + dependencies=[_admin_analyst_dep],
2589 +)
2590 +async def add_case_task_endpoint(
2591 + case_id: int,
2592 + request: CaseTaskCreate,
2593 + current_user: User = Depends(AuthHandler().get_current_user),
2594 + db: AsyncSession = Depends(get_db),
2595 +):
2596 + from app.incidents.services.case_tasks import add_case_task
2597 +
2598 + await _ensure_case_access(case_id, current_user, db)
2599 + return await add_case_task(case_id, request, current_user.username, db)
2600 +
2601 +
2602 +@incidents_db_operations_router.patch(
2603 + "/case/tasks/{task_id}",
2604 + description=(
2605 + "Update a CaseTask: change status (TODO/DONE/NOT_NECESSARY) and/or attach an evidence "
2606 + "comment. NOT_NECESSARY is rejected for mandatory tasks. Admin/analyst only."
2607 + ),
2608 + dependencies=[_admin_analyst_dep],
2609 +)
2610 +async def update_case_task_endpoint(
2611 + task_id: int,
2612 + request: CaseTaskUpdate,
2613 + current_user: User = Depends(AuthHandler().get_current_user),
2614 + db: AsyncSession = Depends(get_db),
2615 +):
2616 + from app.incidents.models import CaseTask
2617 + from app.incidents.services.case_tasks import update_case_task
2618 +
2619 + # Resolve task -> case -> customer access. We do this here rather than
2620 + # in the service so service layer stays auth-agnostic.
2621 + result = await db.execute(select(CaseTask).where(CaseTask.id == task_id))
2622 + task_row = result.scalar_one_or_none()
2623 + if task_row is None:
2624 + raise HTTPException(status_code=404, detail=f"Case task {task_id} not found")
2625 + await _ensure_case_access(task_row.case_id, current_user, db)
2626 +
2627 + return await update_case_task(task_id, request, current_user.username, db)
2628 +
2629 +
2630 +@incidents_db_operations_router.delete(
2631 + "/case/tasks/{task_id}",
2632 + description="Delete a CaseTask (template-derived or custom). Admin/analyst only.",
2633 + dependencies=[_admin_analyst_dep],
2634 +)
2635 +async def delete_case_task_endpoint(
2636 + task_id: int,
2637 + current_user: User = Depends(AuthHandler().get_current_user),
2638 + db: AsyncSession = Depends(get_db),
2639 +):
2640 + from app.incidents.models import CaseTask
2641 + from app.incidents.services.case_tasks import delete_case_task
2642 +
2643 + result = await db.execute(select(CaseTask).where(CaseTask.id == task_id))
2644 + task_row = result.scalar_one_or_none()
2645 + if task_row is None:
2646 + raise HTTPException(status_code=404, detail=f"Case task {task_id} not found")
2647 + await _ensure_case_access(task_row.case_id, current_user, db)
2648 +
2649 + return await delete_case_task(task_id, db)
2650 +
2651 +
2652 +@incidents_db_operations_router.post(
2653 + "/case/{case_id}/apply-template/{template_id}",
2654 + description=(
2655 + "Manually apply a CaseTemplate to an existing case (snapshot-copies its tasks). "
2656 + "Adds to existing tasks rather than replacing them — the analyst can apply multiple "
2657 + "templates over the life of an investigation. Admin/analyst only."
2658 + ),
2659 + dependencies=[_admin_analyst_dep],
2660 +)
2661 +async def apply_template_to_case_endpoint(
2662 + case_id: int,
2663 + template_id: int,
2664 + current_user: User = Depends(AuthHandler().get_current_user),
2665 + db: AsyncSession = Depends(get_db),
2666 +):
2667 + from app.incidents.services.case_tasks import apply_template_to_case
2668 +
2669 + await _ensure_case_access(case_id, current_user, db)
2670 + new_tasks = await apply_template_to_case(
2671 + case_id=case_id,
2672 + template_id=template_id,
2673 + actor=current_user.username,
2674 + session=db,
2675 + commit=True,
2676 + )
2677 + return {
2678 + "success": True,
2679 + "message": f"Applied template id={template_id} to case id={case_id}: {len(new_tasks)} task(s) added",
2680 + "tasks_added": len(new_tasks),
2681 + }
2682 +
2683 +
2684 +# ============================================================================
2685 +# Case Timeline (Phase 4, issue #792)
2686 +#
2687 +# Append-only audit log of every meaningful case mutation. Visible to admin,
2688 +# analyst, and customer_user (read-only) — same scope as the case itself.
2689 +# ============================================================================
2690 +
2691 +
2692 +@incidents_db_operations_router.get(
2693 + "/case/{case_id}/timeline",
2694 + description=(
2695 + "Return the case timeline (append-only audit log of mutations). "
2696 + "Most-recent-first, paginated via limit/offset. Visible to admin, "
2697 + "analyst, and customer_user (read-only)."
2698 + ),
2699 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
2700 +)
2701 +async def get_case_timeline_endpoint(
2702 + case_id: int,
2703 + limit: int = Query(500, ge=1, le=2000, description="Max events to return"),
2704 + offset: int = Query(0, ge=0, description="Skip this many events from the top"),
2705 + current_user: User = Depends(AuthHandler().get_current_user),
2706 + db: AsyncSession = Depends(get_db),
2707 +):
2708 + from app.incidents.services.case_events import list_case_events
2709 +
2710 + await _ensure_case_access(case_id, current_user, db)
2711 + return await list_case_events(case_id, db, limit=limit, offset=offset)
backend/app/incidents/schema/case_templates.py new
+276
@@ -0,0 +1,276 @@
1 +"""
2 +Pydantic schemas for case templates, template tasks, case tasks, and the
3 +case event timeline.
4 +
5 +These schemas back the Phase 2+ routes (template CRUD, task lifecycle,
6 +timeline GET). The underlying SQLModel rows live in ``app.incidents.models``:
7 +``CaseTemplate``, ``CaseTemplateTask``, ``CaseTask``, ``CaseEvent``.
8 +"""
9 +
10 +from datetime import datetime
11 +from enum import Enum
12 +from typing import Any
13 +from typing import Dict
14 +from typing import List
15 +from typing import Optional
16 +
17 +from pydantic import BaseModel
18 +from pydantic import Field
19 +from pydantic import validator
20 +
21 +# ---------------------------------------------------------------------------
22 +# Enums
23 +# ---------------------------------------------------------------------------
24 +
25 +
26 +class CaseTaskStatus(str, Enum):
27 + """Lifecycle status of a CaseTask."""
28 +
29 + TODO = "TODO"
30 + DONE = "DONE"
31 + NOT_NECESSARY = "NOT_NECESSARY"
32 +
33 +
34 +class CaseEventType(str, Enum):
35 + """Allowed CaseEvent.event_type values. Kept in sync with the audit hooks
36 + added in Phase 4. Stored as a string column so unknown values don't fail
37 + reads, but writes should funnel through this enum."""
38 +
39 + CASE_CREATED = "case_created"
40 + CASE_STATUS_CHANGED = "case_status_changed"
41 + CASE_ASSIGNED = "case_assigned"
42 + CASE_ESCALATED = "case_escalated"
43 + ALERT_LINKED = "alert_linked"
44 + ALERT_UNLINKED = "alert_unlinked"
45 + COMMENT_ADDED = "comment_added"
46 + TEMPLATE_APPLIED = "template_applied"
47 + TASK_ADDED = "task_added"
48 + TASK_STATUS_CHANGED = "task_status_changed"
49 + TASK_COMMENTED = "task_commented"
50 +
51 +
52 +# ---------------------------------------------------------------------------
53 +# CaseTemplateTask (template-side definition rows)
54 +# ---------------------------------------------------------------------------
55 +
56 +
57 +class CaseTemplateTaskCreate(BaseModel):
58 + """Payload for adding a task to a template."""
59 +
60 + title: str = Field(..., max_length=500)
61 + description: Optional[str] = None
62 + guidelines: Optional[str] = Field(None, description="Best practices / step-by-step guidance for the analyst")
63 + mandatory: bool = False
64 + order_index: int = Field(0, ge=0)
65 +
66 +
67 +class CaseTemplateTaskUpdate(BaseModel):
68 + """Partial update payload for a template task."""
69 +
70 + title: Optional[str] = Field(None, max_length=500)
71 + description: Optional[str] = None
72 + guidelines: Optional[str] = None
73 + mandatory: Optional[bool] = None
74 + order_index: Optional[int] = Field(None, ge=0)
75 +
76 +
77 +class CaseTemplateTaskResponse(BaseModel):
78 + id: int
79 + template_id: int
80 + title: str
81 + description: Optional[str] = None
82 + guidelines: Optional[str] = None
83 + mandatory: bool
84 + order_index: int
85 +
86 + class Config:
87 + orm_mode = True
88 +
89 +
90 +# ---------------------------------------------------------------------------
91 +# CaseTemplate
92 +# ---------------------------------------------------------------------------
93 +
94 +
95 +class CaseTemplateCreate(BaseModel):
96 + """Payload for creating a new case template (admin/analyst only)."""
97 +
98 + name: str = Field(..., max_length=255)
99 + description: Optional[str] = None
100 + customer_code: Optional[str] = Field(
101 + None,
102 + max_length=50,
103 + description="Customer this template applies to. Omit for a global template.",
104 + )
105 + source: Optional[str] = Field(
106 + None,
107 + max_length=50,
108 + description="Alert source this template applies to (e.g., wazuh, velociraptor). Omit for any source.",
109 + )
110 + is_default: bool = Field(
111 + False,
112 + description="If true, this template is the fallback within its (customer_code, source) scope.",
113 + )
114 + tasks: List[CaseTemplateTaskCreate] = Field(
115 + default_factory=list,
116 + description="Initial task list. More can be added later via the task endpoints.",
117 + )
118 +
119 +
120 +class CaseTemplateUpdate(BaseModel):
121 + """Partial update payload for template metadata. Tasks are managed via
122 + their own endpoints, not through this schema."""
123 +
124 + name: Optional[str] = Field(None, max_length=255)
125 + description: Optional[str] = None
126 + customer_code: Optional[str] = Field(None, max_length=50)
127 + source: Optional[str] = Field(None, max_length=50)
128 + is_default: Optional[bool] = None
129 +
130 +
131 +class CaseTemplateResponse(BaseModel):
132 + id: int
133 + name: str
134 + description: Optional[str] = None
135 + customer_code: Optional[str] = None
136 + source: Optional[str] = None
137 + is_default: bool
138 + created_by: str
139 + created_at: datetime
140 + updated_at: datetime
141 + tasks: List[CaseTemplateTaskResponse] = Field(default_factory=list)
142 +
143 + class Config:
144 + orm_mode = True
145 +
146 +
147 +class CaseTemplateListResponse(BaseModel):
148 + templates: List[CaseTemplateResponse] = Field(default_factory=list)
149 + success: bool
150 + message: str
151 +
152 +
153 +class CaseTemplateOperationResponse(BaseModel):
154 + template: Optional[CaseTemplateResponse] = None
155 + success: bool
156 + message: str
157 +
158 +
159 +class CaseTemplateTaskOperationResponse(BaseModel):
160 + task: Optional[CaseTemplateTaskResponse] = None
161 + success: bool
162 + message: str
163 +
164 +
165 +# ---------------------------------------------------------------------------
166 +# CaseTask (case-side instance rows)
167 +# ---------------------------------------------------------------------------
168 +
169 +
170 +class CaseTaskCreate(BaseModel):
171 + """Payload for adding a custom task to an existing case (analyst-driven)."""
172 +
173 + title: str = Field(..., max_length=500)
174 + description: Optional[str] = None
175 + guidelines: Optional[str] = None
176 + mandatory: bool = False
177 + order_index: int = Field(0, ge=0)
178 +
179 +
180 +class CaseTaskUpdate(BaseModel):
181 + """
182 + Partial update payload for a case task.
183 +
184 + Status transitions to NOT_NECESSARY are rejected at the service layer
185 + when the task is mandatory. ``evidence_comment`` is intended for free-form
186 + notes / log snippets / command output captured alongside the status change.
187 + """
188 +
189 + status: Optional[CaseTaskStatus] = None
190 + evidence_comment: Optional[str] = None
191 +
192 + @validator("status")
193 + def _status_must_be_known(cls, v: Optional[CaseTaskStatus]) -> Optional[CaseTaskStatus]:
194 + # Pydantic already enforces enum membership; this guard is for clarity
195 + # and to catch any future string-coercion shenanigans.
196 + if v is not None and v not in CaseTaskStatus:
197 + raise ValueError(f"Unknown task status: {v}")
198 + return v
199 +
200 +
201 +class CaseTaskResponse(BaseModel):
202 + id: int
203 + case_id: int
204 + template_task_id: Optional[int] = None
205 + title: str
206 + description: Optional[str] = None
207 + guidelines: Optional[str] = None
208 + mandatory: bool
209 + order_index: int
210 + status: CaseTaskStatus
211 + evidence_comment: Optional[str] = None
212 + completed_by: Optional[str] = None
213 + completed_at: Optional[datetime] = None
214 + created_by: str
215 + created_at: datetime
216 + updated_at: datetime
217 +
218 + class Config:
219 + orm_mode = True
220 +
221 +
222 +class CaseTaskListResponse(BaseModel):
223 + tasks: List[CaseTaskResponse] = Field(default_factory=list)
224 + success: bool
225 + message: str
226 +
227 +
228 +class CaseTaskOperationResponse(BaseModel):
229 + task: Optional[CaseTaskResponse] = None
230 + success: bool
231 + message: str
232 +
233 +
234 +# ---------------------------------------------------------------------------
235 +# Soft-warning payload returned when an analyst tries to close a case with
236 +# incomplete mandatory tasks. The route returns this object with HTTP 200 and
237 +# the case is NOT closed; the caller re-submits with ?force=true to confirm.
238 +# ---------------------------------------------------------------------------
239 +
240 +
241 +class CaseCloseWarningResponse(BaseModel):
242 + """
243 + Soft-warning response when closing a case with incomplete mandatory tasks.
244 +
245 + The case is NOT closed when this response is returned. Re-submit the close
246 + request with ``force=true`` to override the warning.
247 + """
248 +
249 + success: bool = False
250 + requires_confirmation: bool = True
251 + message: str
252 + incomplete_mandatory_tasks: List[CaseTaskResponse] = Field(default_factory=list)
253 +
254 +
255 +# ---------------------------------------------------------------------------
256 +# CaseEvent / Timeline
257 +# ---------------------------------------------------------------------------
258 +
259 +
260 +class CaseEventResponse(BaseModel):
261 + id: int
262 + case_id: int
263 + event_type: str
264 + actor: str
265 + timestamp: datetime
266 + payload: Optional[Dict[str, Any]] = None
267 +
268 + class Config:
269 + orm_mode = True
270 +
271 +
272 +class CaseTimelineResponse(BaseModel):
273 + case_id: int
274 + events: List[CaseEventResponse] = Field(default_factory=list)
275 + success: bool
276 + message: str
backend/app/incidents/services/case_events.py new
+168
@@ -0,0 +1,168 @@
1 +"""
2 +Service helpers for the case timeline / audit log (issue #792, Phase 4).
3 +
4 +The ``CaseEvent`` table is append-only. Every meaningful case mutation
5 +(status change, alert link, assignment, escalation, comment, template
6 +application, task add/status change) writes one row here. The timeline
7 +view (``GET /case/{id}/timeline``) reads from this table.
8 +
9 +Design notes:
10 +- Emits never raise — a failed audit write should not break the
11 + underlying mutation. We log and swallow.
12 +- Service-side emits are used where the same logic is invoked from
13 + multiple call sites (e.g., ``apply_template_to_case`` runs from both
14 + the create-from-alert hook and the manual apply endpoint, so its
15 + emit lives in the service).
16 +- Route-side emits are used where the actor is naturally available at
17 + the route layer (most case mutation routes already resolve
18 + ``current_user``). This keeps the service layer auth-agnostic.
19 +"""
20 +
21 +from datetime import datetime
22 +from typing import Any
23 +from typing import Dict
24 +from typing import List
25 +from typing import Optional
26 +
27 +from loguru import logger
28 +from sqlalchemy import select
29 +from sqlalchemy.ext.asyncio import AsyncSession
30 +
31 +from app.incidents.models import CaseEvent
32 +from app.incidents.schema.case_templates import CaseEventResponse
33 +from app.incidents.schema.case_templates import CaseEventType
34 +from app.incidents.schema.case_templates import CaseTimelineResponse
35 +
36 +
37 +async def emit_case_event(
38 + session: AsyncSession,
39 + case_id: int,
40 + event_type: CaseEventType,
41 + actor: str,
42 + payload: Optional[Dict[str, Any]] = None,
43 + *,
44 + commit: bool = False,
45 +) -> None:
46 + """
47 + Append one CaseEvent row.
48 +
49 + Set ``commit=True`` for top-level emits (the mutation has already
50 + persisted). Use ``commit=False`` when emitting from inside an
51 + existing transaction so the audit row lands atomically with the
52 + underlying mutation — the caller commits.
53 +
54 + Failures are logged but never raised: the audit log should not
55 + cause user-facing 500s.
56 + """
57 + try:
58 + event = CaseEvent(
59 + case_id=case_id,
60 + event_type=event_type.value if isinstance(event_type, CaseEventType) else str(event_type),
61 + actor=actor or "system",
62 + timestamp=datetime.utcnow(),
63 + payload=payload or None,
64 + )
65 + session.add(event)
66 + if commit:
67 + await session.commit()
68 + except Exception as e:
69 + logger.warning(
70 + f"Failed to emit CaseEvent (case_id={case_id}, type={event_type}, actor={actor}): {e}",
71 + )
72 +
73 +
74 +async def list_case_events(
75 + case_id: int,
76 + session: AsyncSession,
77 + *,
78 + limit: int = 500,
79 + offset: int = 0,
80 +) -> CaseTimelineResponse:
81 + """
82 + Return the case timeline ordered most-recent-first. The ``limit``
83 + cap protects the client; cases with very long histories can paginate
84 + via ``offset``.
85 + """
86 + try:
87 + stmt = (
88 + select(CaseEvent)
89 + .where(CaseEvent.case_id == case_id)
90 + .order_by(CaseEvent.timestamp.desc(), CaseEvent.id.desc())
91 + .limit(limit)
92 + .offset(offset)
93 + )
94 + result = await session.execute(stmt)
95 + events = result.scalars().all()
96 +
97 + return CaseTimelineResponse(
98 + case_id=case_id,
99 + events=[
100 + CaseEventResponse(
101 + id=e.id,
102 + case_id=e.case_id,
103 + event_type=e.event_type,
104 + actor=e.actor,
105 + timestamp=e.timestamp,
106 + payload=e.payload,
107 + )
108 + for e in events
109 + ],
110 + success=True,
111 + message=f"Retrieved {len(events)} timeline event(s) for case id={case_id}",
112 + )
113 + except Exception as e:
114 + logger.error(f"Failed to load timeline for case id={case_id}: {e}")
115 + return CaseTimelineResponse(
116 + case_id=case_id,
117 + events=[],
118 + success=False,
119 + message=f"Failed to load case timeline: {e}",
120 + )
121 +
122 +
123 +# ---------------------------------------------------------------------------
124 +# Convenience constructors for the typed payloads we emit. Keeps the
125 +# event_type/payload shapes consistent across emit sites.
126 +# ---------------------------------------------------------------------------
127 +
128 +
129 +def payload_status_change(from_status: Optional[str], to_status: str, forced: bool = False) -> Dict[str, Any]:
130 + return {"from": from_status, "to": to_status, "forced": forced}
131 +
132 +
133 +def payload_assignment(from_assignee: Optional[str], to_assignee: Optional[str]) -> Dict[str, Any]:
134 + return {"from": from_assignee, "to": to_assignee}
135 +
136 +
137 +def payload_escalation(escalated: bool) -> Dict[str, Any]:
138 + return {"escalated": escalated}
139 +
140 +
141 +def payload_alert_link(alert_id: int) -> Dict[str, Any]:
142 + return {"alert_id": alert_id}
143 +
144 +
145 +def payload_alert_links_bulk(alert_ids: List[int]) -> Dict[str, Any]:
146 + return {"alert_ids": list(alert_ids), "count": len(alert_ids)}
147 +
148 +
149 +def payload_comment(comment_id: int, snippet: Optional[str] = None) -> Dict[str, Any]:
150 + """``snippet`` is a short preview (first ~140 chars) for the timeline UI."""
151 + out: Dict[str, Any] = {"comment_id": comment_id}
152 + if snippet:
153 + out["snippet"] = snippet[:140]
154 + return out
155 +
156 +
157 +def payload_template_applied(template_id: int, template_name: str, tasks_added: int) -> Dict[str, Any]:
158 + return {
159 + "template_id": template_id,
160 + "template_name": template_name,
161 + "tasks_added": tasks_added,
162 + }
163 +
164 +
165 +def payload_task(task_id: int, title: str, mandatory: bool, **extra: Any) -> Dict[str, Any]:
166 + out: Dict[str, Any] = {"task_id": task_id, "title": title, "mandatory": mandatory}
167 + out.update(extra)
168 + return out
backend/app/incidents/services/case_tasks.py new
+578
@@ -0,0 +1,578 @@
1 +"""
2 +Service layer for case tasks (issue #792, Phase 3).
3 +
4 +Responsibilities:
5 +- Selecting a CaseTemplate for a newly-created Case based on
6 + ``(customer_code, source)`` with a documented priority order.
7 +- Snapshot-copying ``CaseTemplateTask`` rows into ``CaseTask`` rows on
8 + case creation (or post-creation manual apply).
9 +- CRUD on CaseTask rows (analyst-driven custom adds, status updates with
10 + evidence comments, deletes).
11 +- The "soft-warning on close" check that's wired into the existing
12 + ``/case/status`` endpoint by the route layer.
13 +
14 +Authorization is enforced at the route layer; this module is auth-agnostic.
15 +"""
16 +
17 +from datetime import datetime
18 +from typing import List
19 +from typing import Optional
20 +from typing import Tuple
21 +
22 +from loguru import logger
23 +from sqlalchemy import select
24 +from sqlalchemy.ext.asyncio import AsyncSession
25 +from sqlalchemy.orm import selectinload
26 +
27 +from app.incidents.models import Alert
28 +from app.incidents.models import Case
29 +from app.incidents.models import CaseAlertLink
30 +from app.incidents.models import CaseTask
31 +from app.incidents.models import CaseTemplate
32 +from app.incidents.schema.case_templates import CaseCloseWarningResponse
33 +from app.incidents.schema.case_templates import CaseEventType
34 +from app.incidents.schema.case_templates import CaseTaskCreate
35 +from app.incidents.schema.case_templates import CaseTaskListResponse
36 +from app.incidents.schema.case_templates import CaseTaskOperationResponse
37 +from app.incidents.schema.case_templates import CaseTaskResponse
38 +from app.incidents.schema.case_templates import CaseTaskStatus
39 +from app.incidents.schema.case_templates import CaseTaskUpdate
40 +
41 +# ---------------------------------------------------------------------------
42 +# Conversions
43 +# ---------------------------------------------------------------------------
44 +
45 +
46 +def _case_task_to_response(task: CaseTask) -> CaseTaskResponse:
47 + return CaseTaskResponse(
48 + id=task.id,
49 + case_id=task.case_id,
50 + template_task_id=task.template_task_id,
51 + title=task.title,
52 + description=task.description,
53 + guidelines=task.guidelines,
54 + mandatory=task.mandatory,
55 + order_index=task.order_index,
56 + status=CaseTaskStatus(task.status),
57 + evidence_comment=task.evidence_comment,
58 + completed_by=task.completed_by,
59 + completed_at=task.completed_at,
60 + created_by=task.created_by,
61 + created_at=task.created_at,
62 + updated_at=task.updated_at,
63 + )
64 +
65 +
66 +# ---------------------------------------------------------------------------
67 +# Template selection (for case creation)
68 +# ---------------------------------------------------------------------------
69 +
70 +
71 +async def pick_template_for_case(
72 + customer_code: Optional[str],
73 + source: Optional[str],
74 + session: AsyncSession,
75 +) -> Optional[CaseTemplate]:
76 + """
77 + Pick the most-specific applicable template for a newly created case.
78 +
79 + Priority order (each step short-circuits the next):
80 +
81 + 1. ``customer_code`` + ``source`` exact match, prefer is_default
82 + 2. ``customer_code`` only (source IS NULL), prefer is_default
83 + 3. ``source`` only (customer_code IS NULL), prefer is_default
84 + 4. Global default (both NULL, is_default=True)
85 +
86 + Returns the template with tasks eagerly loaded, or None if no match.
87 + """
88 +
89 + async def _query(customer_filter, source_filter) -> Optional[CaseTemplate]:
90 + stmt = (
91 + select(CaseTemplate)
92 + .options(selectinload(CaseTemplate.tasks))
93 + .where(customer_filter)
94 + .where(source_filter)
95 + .order_by(CaseTemplate.is_default.desc(), CaseTemplate.created_at.desc())
96 + )
97 + result = await session.execute(stmt)
98 + return result.scalars().first()
99 +
100 + # Step 1 — both match
101 + if customer_code is not None and source is not None:
102 + match = await _query(
103 + CaseTemplate.customer_code == customer_code,
104 + CaseTemplate.source == source,
105 + )
106 + if match is not None:
107 + return match
108 +
109 + # Step 2 — customer only
110 + if customer_code is not None:
111 + match = await _query(
112 + CaseTemplate.customer_code == customer_code,
113 + CaseTemplate.source.is_(None),
114 + )
115 + if match is not None:
116 + return match
117 +
118 + # Step 3 — source only
119 + if source is not None:
120 + match = await _query(
121 + CaseTemplate.customer_code.is_(None),
122 + CaseTemplate.source == source,
123 + )
124 + if match is not None:
125 + return match
126 +
127 + # Step 4 — global default
128 + match = await _query(
129 + CaseTemplate.customer_code.is_(None),
130 + CaseTemplate.source.is_(None),
131 + )
132 + if match is not None and match.is_default:
133 + return match
134 +
135 + return None
136 +
137 +
138 +# ---------------------------------------------------------------------------
139 +# Template application (snapshot copy)
140 +# ---------------------------------------------------------------------------
141 +
142 +
143 +async def apply_template_to_case(
144 + case_id: int,
145 + template_id: int,
146 + actor: str,
147 + session: AsyncSession,
148 + *,
149 + commit: bool = True,
150 +) -> List[CaseTask]:
151 + """
152 + Snapshot-copy every CaseTemplateTask on the named template into a new
153 + CaseTask row attached to the case.
154 +
155 + Tasks are *snapshots* — editing the source template later does not
156 + mutate the CaseTask rows. ``template_task_id`` is preserved as a soft
157 + link for analytics / future "this came from template X" UI hints.
158 +
159 + Set ``commit=False`` when calling from inside another transaction
160 + (e.g., immediately after a Case is created in the same flow); the
161 + caller is then responsible for the commit.
162 + """
163 + template_result = await session.execute(
164 + select(CaseTemplate).where(CaseTemplate.id == template_id).options(selectinload(CaseTemplate.tasks)),
165 + )
166 + template = template_result.scalar_one_or_none()
167 + if template is None:
168 + logger.warning(f"apply_template_to_case: template id={template_id} not found")
169 + return []
170 +
171 + case_result = await session.execute(select(Case).where(Case.id == case_id))
172 + case = case_result.scalar_one_or_none()
173 + if case is None:
174 + logger.warning(f"apply_template_to_case: case id={case_id} not found")
175 + return []
176 +
177 + new_tasks: List[CaseTask] = []
178 + for tmpl_task in sorted(template.tasks, key=lambda t: (t.order_index, t.id)):
179 + case_task = CaseTask(
180 + case_id=case_id,
181 + template_task_id=tmpl_task.id,
182 + title=tmpl_task.title,
183 + description=tmpl_task.description,
184 + guidelines=tmpl_task.guidelines,
185 + mandatory=tmpl_task.mandatory,
186 + order_index=tmpl_task.order_index,
187 + status=CaseTaskStatus.TODO.value,
188 + evidence_comment=None,
189 + completed_by=None,
190 + completed_at=None,
191 + created_by=actor,
192 + )
193 + session.add(case_task)
194 + new_tasks.append(case_task)
195 +
196 + # Audit emits (Phase 4): one template_applied event for the operation,
197 + # plus one task_added event per snapshotted task. Imported lazily to
198 + # avoid a circular import — case_events doesn't depend on case_tasks
199 + # but isort/lint sometimes resolves these eagerly.
200 + from app.incidents.services.case_events import emit_case_event
201 + from app.incidents.services.case_events import payload_task
202 + from app.incidents.services.case_events import payload_template_applied
203 +
204 + await session.flush() # ensure case_task.id is populated for the audit payloads
205 +
206 + await emit_case_event(
207 + session=session,
208 + case_id=case_id,
209 + event_type=CaseEventType.TEMPLATE_APPLIED,
210 + actor=actor,
211 + payload=payload_template_applied(
212 + template_id=template.id,
213 + template_name=template.name,
214 + tasks_added=len(new_tasks),
215 + ),
216 + commit=False,
217 + )
218 + for ct in new_tasks:
219 + await emit_case_event(
220 + session=session,
221 + case_id=case_id,
222 + event_type=CaseEventType.TASK_ADDED,
223 + actor=actor,
224 + payload=payload_task(
225 + task_id=ct.id,
226 + title=ct.title,
227 + mandatory=ct.mandatory,
228 + source="template",
229 + template_id=template.id,
230 + ),
231 + commit=False,
232 + )
233 +
234 + if commit:
235 + await session.commit()
236 + for t in new_tasks:
237 + await session.refresh(t)
238 + logger.info(
239 + f"Applied template id={template_id} ('{template.name}') to case id={case_id}: " f"{len(new_tasks)} task(s) created by {actor}",
240 + )
241 + else:
242 + logger.info(
243 + f"Staged template id={template_id} ('{template.name}') for case id={case_id}: " f"{len(new_tasks)} task(s) (uncommitted)",
244 + )
245 +
246 + return new_tasks
247 +
248 +
249 +async def auto_apply_template_for_new_case(
250 + case: Case,
251 + actor: str,
252 + session: AsyncSession,
253 + *,
254 + source_hint: Optional[str] = None,
255 +) -> Optional[Tuple[CaseTemplate, List[CaseTask]]]:
256 + """
257 + Convenience wrapper used from ``create_case_from_alert`` and
258 + ``create_case``. Picks the best matching template using the case's
259 + customer_code and an optional ``source_hint`` (the originating
260 + alert's source — first-alert-wins per Phase 3 design decision).
261 +
262 + Returns (template, tasks) on success, or None if no template
263 + matched. ``commit=False`` so the caller's transaction stays in
264 + control; caller commits after this returns.
265 + """
266 + template = await pick_template_for_case(
267 + customer_code=case.customer_code,
268 + source=source_hint,
269 + session=session,
270 + )
271 + if template is None:
272 + return None
273 +
274 + tasks = await apply_template_to_case(
275 + case_id=case.id,
276 + template_id=template.id,
277 + actor=actor,
278 + session=session,
279 + commit=False,
280 + )
281 + return template, tasks
282 +
283 +
284 +# ---------------------------------------------------------------------------
285 +# Case task CRUD
286 +# ---------------------------------------------------------------------------
287 +
288 +
289 +async def list_case_tasks(case_id: int, session: AsyncSession) -> CaseTaskListResponse:
290 + try:
291 + stmt = select(CaseTask).where(CaseTask.case_id == case_id).order_by(CaseTask.order_index, CaseTask.id)
292 + result = await session.execute(stmt)
293 + tasks = result.scalars().all()
294 + return CaseTaskListResponse(
295 + tasks=[_case_task_to_response(t) for t in tasks],
296 + success=True,
297 + message=f"Retrieved {len(tasks)} task(s) for case id={case_id}",
298 + )
299 + except Exception as e:
300 + logger.error(f"Failed to list tasks for case id={case_id}: {e}")
301 + return CaseTaskListResponse(
302 + tasks=[],
303 + success=False,
304 + message=f"Failed to list case tasks: {e}",
305 + )
306 +
307 +
308 +async def add_case_task(
309 + case_id: int,
310 + request: CaseTaskCreate,
311 + actor: str,
312 + session: AsyncSession,
313 +) -> CaseTaskOperationResponse:
314 + """Add a custom task to a case mid-investigation. template_task_id is NULL."""
315 + try:
316 + case_result = await session.execute(select(Case).where(Case.id == case_id))
317 + if case_result.scalar_one_or_none() is None:
318 + return CaseTaskOperationResponse(
319 + task=None,
320 + success=False,
321 + message=f"Case id={case_id} not found",
322 + )
323 +
324 + task = CaseTask(
325 + case_id=case_id,
326 + template_task_id=None,
327 + title=request.title,
328 + description=request.description,
329 + guidelines=request.guidelines,
330 + mandatory=request.mandatory,
331 + order_index=request.order_index,
332 + status=CaseTaskStatus.TODO.value,
333 + created_by=actor,
334 + )
335 + session.add(task)
336 + await session.flush()
337 +
338 + from app.incidents.services.case_events import emit_case_event
339 + from app.incidents.services.case_events import payload_task
340 +
341 + await emit_case_event(
342 + session=session,
343 + case_id=case_id,
344 + event_type=CaseEventType.TASK_ADDED,
345 + actor=actor,
346 + payload=payload_task(
347 + task_id=task.id,
348 + title=task.title,
349 + mandatory=task.mandatory,
350 + source="custom",
351 + ),
352 + commit=False,
353 + )
354 +
355 + await session.commit()
356 + await session.refresh(task)
357 +
358 + return CaseTaskOperationResponse(
359 + task=_case_task_to_response(task),
360 + success=True,
361 + message=f"Added task id={task.id} to case id={case_id}",
362 + )
363 + except Exception as e:
364 + logger.error(f"Failed to add task to case id={case_id}: {e}")
365 + await session.rollback()
366 + return CaseTaskOperationResponse(
367 + task=None,
368 + success=False,
369 + message=f"Failed to add case task: {e}",
370 + )
371 +
372 +
373 +async def update_case_task(
374 + task_id: int,
375 + request: CaseTaskUpdate,
376 + actor: str,
377 + session: AsyncSession,
378 +) -> CaseTaskOperationResponse:
379 + """
380 + Update task status and/or evidence comment. Validates that
381 + NOT_NECESSARY isn't applied to a mandatory task. Sets/unsets
382 + ``completed_by`` and ``completed_at`` based on the resulting status.
383 + """
384 + try:
385 + result = await session.execute(select(CaseTask).where(CaseTask.id == task_id))
386 + task = result.scalar_one_or_none()
387 + if task is None:
388 + return CaseTaskOperationResponse(
389 + task=None,
390 + success=False,
391 + message=f"Case task id={task_id} not found",
392 + )
393 +
394 + fields_set = request.__fields_set__
395 + previous_status = task.status
396 + status_changed = False
397 +
398 + if "status" in fields_set and request.status is not None:
399 + new_status = request.status
400 + if new_status == CaseTaskStatus.NOT_NECESSARY and task.mandatory:
401 + return CaseTaskOperationResponse(
402 + task=_case_task_to_response(task),
403 + success=False,
404 + message="Mandatory tasks cannot be marked NOT_NECESSARY.",
405 + )
406 + if new_status.value != task.status:
407 + task.status = new_status.value
408 + status_changed = True
409 +
410 + # Maintain completed_by / completed_at to reflect the resulting state.
411 + if new_status in (CaseTaskStatus.DONE, CaseTaskStatus.NOT_NECESSARY):
412 + task.completed_by = actor
413 + task.completed_at = datetime.utcnow()
414 + else:
415 + # Returning to TODO clears the completion record.
416 + task.completed_by = None
417 + task.completed_at = None
418 +
419 + comment_set_this_call = "evidence_comment" in fields_set
420 + if comment_set_this_call:
421 + task.evidence_comment = request.evidence_comment
422 +
423 + task.updated_at = datetime.utcnow()
424 + session.add(task)
425 +
426 + # Audit emits (Phase 4): two distinct events when both fire — the UI
427 + # can render them as a single block but the data model keeps them
428 + # separate so a comment-only update still appears in the timeline.
429 + from app.incidents.services.case_events import emit_case_event
430 + from app.incidents.services.case_events import payload_task
431 +
432 + if status_changed:
433 + await emit_case_event(
434 + session=session,
435 + case_id=task.case_id,
436 + event_type=CaseEventType.TASK_STATUS_CHANGED,
437 + actor=actor,
438 + payload=payload_task(
439 + task_id=task.id,
440 + title=task.title,
441 + mandatory=task.mandatory,
442 + from_status=previous_status,
443 + to_status=task.status,
444 + ),
445 + commit=False,
446 + )
447 +
448 + if comment_set_this_call and request.evidence_comment:
449 + await emit_case_event(
450 + session=session,
451 + case_id=task.case_id,
452 + event_type=CaseEventType.TASK_COMMENTED,
453 + actor=actor,
454 + payload=payload_task(
455 + task_id=task.id,
456 + title=task.title,
457 + mandatory=task.mandatory,
458 + snippet=request.evidence_comment[:140],
459 + ),
460 + commit=False,
461 + )
462 +
463 + await session.commit()
464 + await session.refresh(task)
465 +
466 + return CaseTaskOperationResponse(
467 + task=_case_task_to_response(task),
468 + success=True,
469 + message=f"Updated case task id={task_id}",
470 + )
471 + except Exception as e:
472 + logger.error(f"Failed to update case task id={task_id}: {e}")
473 + await session.rollback()
474 + return CaseTaskOperationResponse(
475 + task=None,
476 + success=False,
477 + message=f"Failed to update case task: {e}",
478 + )
479 +
480 +
481 +async def delete_case_task(
482 + task_id: int,
483 + session: AsyncSession,
484 +) -> CaseTaskOperationResponse:
485 + """
486 + Delete a case task. Allowed against template-derived tasks too —
487 + if the analyst genuinely doesn't want the task tracked, deletion
488 + is more honest than NOT_NECESSARY (which is reserved for
489 + "intentionally skipped during this investigation").
490 + """
491 + try:
492 + result = await session.execute(select(CaseTask).where(CaseTask.id == task_id))
493 + task = result.scalar_one_or_none()
494 + if task is None:
495 + return CaseTaskOperationResponse(
496 + task=None,
497 + success=False,
498 + message=f"Case task id={task_id} not found",
499 + )
500 + snapshot = _case_task_to_response(task)
501 + await session.delete(task)
502 + await session.commit()
503 + return CaseTaskOperationResponse(
504 + task=snapshot,
505 + success=True,
506 + message=f"Deleted case task id={task_id}",
507 + )
508 + except Exception as e:
509 + logger.error(f"Failed to delete case task id={task_id}: {e}")
510 + await session.rollback()
511 + return CaseTaskOperationResponse(
512 + task=None,
513 + success=False,
514 + message=f"Failed to delete case task: {e}",
515 + )
516 +
517 +
518 +# ---------------------------------------------------------------------------
519 +# Soft-warning support
520 +# ---------------------------------------------------------------------------
521 +
522 +
523 +async def get_incomplete_mandatory_tasks(
524 + case_id: int,
525 + session: AsyncSession,
526 +) -> List[CaseTask]:
527 + """
528 + Return mandatory tasks on the named case whose status is not DONE.
529 + Used by the close-case route to drive the soft-warning response.
530 +
531 + Note: NOT_NECESSARY is never a valid status for a mandatory task
532 + (enforced in update_case_task), so the only "completed" terminal
533 + state for a mandatory task is DONE.
534 + """
535 + stmt = (
536 + select(CaseTask)
537 + .where(CaseTask.case_id == case_id)
538 + .where(CaseTask.mandatory == True) # noqa: E712
539 + .where(CaseTask.status != CaseTaskStatus.DONE.value)
540 + .order_by(CaseTask.order_index, CaseTask.id)
541 + )
542 + result = await session.execute(stmt)
543 + return list(result.scalars().all())
544 +
545 +
546 +def build_close_warning_response(incomplete: List[CaseTask]) -> CaseCloseWarningResponse:
547 + return CaseCloseWarningResponse(
548 + success=False,
549 + requires_confirmation=True,
550 + message=(f"{len(incomplete)} mandatory task(s) are not marked DONE. " "Re-submit with force=true to close anyway."),
551 + incomplete_mandatory_tasks=[_case_task_to_response(t) for t in incomplete],
552 + )
553 +
554 +
555 +# ---------------------------------------------------------------------------
556 +# Convenience: derive first-linked-alert source for create_case path
557 +# ---------------------------------------------------------------------------
558 +
559 +
560 +async def get_first_alert_source_for_case(
561 + case_id: int,
562 + session: AsyncSession,
563 +) -> Optional[str]:
564 + """
565 + Return the alert.source of the lowest-id alert linked to the case,
566 + or None if no alerts are linked. Used by the manual create_case path
567 + (which doesn't naturally know the source) AFTER the analyst links
568 + an alert and wants to apply a template.
569 + """
570 + stmt = (
571 + select(Alert.source)
572 + .join(CaseAlertLink, CaseAlertLink.alert_id == Alert.id)
573 + .where(CaseAlertLink.case_id == case_id)
574 + .order_by(Alert.id.asc())
575 + .limit(1)
576 + )
577 + result = await session.execute(stmt)
578 + return result.scalar_one_or_none()
backend/app/incidents/services/case_templates.py new
+578
@@ -0,0 +1,578 @@
1 +"""
2 +Service layer for case template CRUD (issue #792, Phase 2).
3 +
4 +Templates are scoped by ``customer_code`` (NULL = global) and ``source``
5 +(NULL = any alert source). Rows are managed exclusively by admin/analyst
6 +operators — the route layer enforces the auth scope, this layer is auth-
7 +agnostic and only handles persistence and validation.
8 +
9 +Tasks within a template are exposed via separate functions
10 +(``add_template_task`` etc.) so the API can support incremental task
11 +authoring without requiring a full template replacement.
12 +
13 +Phase 3 will introduce ``pick_template`` for case-creation-time
14 +selection and the snapshot-copy of CaseTemplateTask -> CaseTask.
15 +"""
16 +
17 +from datetime import datetime
18 +from typing import List
19 +from typing import Optional
20 +
21 +from loguru import logger
22 +from sqlalchemy import select
23 +from sqlalchemy.ext.asyncio import AsyncSession
24 +from sqlalchemy.orm import selectinload
25 +
26 +from app.incidents.models import CaseTemplate
27 +from app.incidents.models import CaseTemplateTask
28 +from app.incidents.schema.case_templates import CaseTemplateCreate
29 +from app.incidents.schema.case_templates import CaseTemplateListResponse
30 +from app.incidents.schema.case_templates import CaseTemplateOperationResponse
31 +from app.incidents.schema.case_templates import CaseTemplateResponse
32 +from app.incidents.schema.case_templates import CaseTemplateTaskCreate
33 +from app.incidents.schema.case_templates import CaseTemplateTaskOperationResponse
34 +from app.incidents.schema.case_templates import CaseTemplateTaskResponse
35 +from app.incidents.schema.case_templates import CaseTemplateTaskUpdate
36 +from app.incidents.schema.case_templates import CaseTemplateUpdate
37 +
38 +# ---------------------------------------------------------------------------
39 +# Internal helpers
40 +# ---------------------------------------------------------------------------
41 +
42 +
43 +def _template_task_to_response(task: CaseTemplateTask) -> CaseTemplateTaskResponse:
44 + return CaseTemplateTaskResponse(
45 + id=task.id,
46 + template_id=task.template_id,
47 + title=task.title,
48 + description=task.description,
49 + guidelines=task.guidelines,
50 + mandatory=task.mandatory,
51 + order_index=task.order_index,
52 + )
53 +
54 +
55 +def _template_to_response(template: CaseTemplate) -> CaseTemplateResponse:
56 + tasks_sorted = sorted(template.tasks or [], key=lambda t: (t.order_index, t.id))
57 + return CaseTemplateResponse(
58 + id=template.id,
59 + name=template.name,
60 + description=template.description,
61 + customer_code=template.customer_code,
62 + source=template.source,
63 + is_default=template.is_default,
64 + created_by=template.created_by,
65 + created_at=template.created_at,
66 + updated_at=template.updated_at,
67 + tasks=[_template_task_to_response(t) for t in tasks_sorted],
68 + )
69 +
70 +
71 +async def _load_template_with_tasks(
72 + template_id: int,
73 + session: AsyncSession,
74 +) -> Optional[CaseTemplate]:
75 + stmt = select(CaseTemplate).where(CaseTemplate.id == template_id).options(selectinload(CaseTemplate.tasks))
76 + result = await session.execute(stmt)
77 + return result.scalar_one_or_none()
78 +
79 +
80 +async def _enforce_single_default(
81 + customer_code: Optional[str],
82 + source: Optional[str],
83 + exclude_template_id: Optional[int],
84 + session: AsyncSession,
85 +) -> None:
86 + """
87 + Demote any other ``is_default`` template that shares the same
88 + (customer_code, source) scope. Keeps default selection unambiguous.
89 + """
90 + stmt = (
91 + select(CaseTemplate)
92 + .where(CaseTemplate.is_default == True) # noqa: E712 - SQL boolean
93 + .where(CaseTemplate.customer_code.is_(None) if customer_code is None else CaseTemplate.customer_code == customer_code)
94 + .where(CaseTemplate.source.is_(None) if source is None else CaseTemplate.source == source)
95 + )
96 + if exclude_template_id is not None:
97 + stmt = stmt.where(CaseTemplate.id != exclude_template_id)
98 +
99 + result = await session.execute(stmt)
100 + others = result.scalars().all()
101 + for other in others:
102 + other.is_default = False
103 + other.updated_at = datetime.utcnow()
104 + session.add(other)
105 +
106 +
107 +# ---------------------------------------------------------------------------
108 +# Template CRUD
109 +# ---------------------------------------------------------------------------
110 +
111 +
112 +async def create_template(
113 + request: CaseTemplateCreate,
114 + actor: str,
115 + session: AsyncSession,
116 +) -> CaseTemplateOperationResponse:
117 + """Create a new template with optional initial tasks."""
118 + logger.info(f"Creating case template '{request.name}' by {actor}")
119 +
120 + try:
121 + if request.is_default:
122 + await _enforce_single_default(
123 + customer_code=request.customer_code,
124 + source=request.source,
125 + exclude_template_id=None,
126 + session=session,
127 + )
128 +
129 + template = CaseTemplate(
130 + name=request.name,
131 + description=request.description,
132 + customer_code=request.customer_code,
133 + source=request.source,
134 + is_default=request.is_default,
135 + created_by=actor,
136 + )
137 + session.add(template)
138 + await session.flush() # populate template.id before adding tasks
139 +
140 + for task_payload in request.tasks:
141 + session.add(
142 + CaseTemplateTask(
143 + template_id=template.id,
144 + title=task_payload.title,
145 + description=task_payload.description,
146 + guidelines=task_payload.guidelines,
147 + mandatory=task_payload.mandatory,
148 + order_index=task_payload.order_index,
149 + ),
150 + )
151 +
152 + await session.commit()
153 +
154 + loaded = await _load_template_with_tasks(template.id, session)
155 + return CaseTemplateOperationResponse(
156 + template=_template_to_response(loaded),
157 + success=True,
158 + message=f"Created template id={template.id}",
159 + )
160 +
161 + except Exception as e:
162 + logger.error(f"Failed to create case template: {e}")
163 + await session.rollback()
164 + return CaseTemplateOperationResponse(
165 + template=None,
166 + success=False,
167 + message=f"Failed to create case template: {e}",
168 + )
169 +
170 +
171 +async def list_templates(
172 + session: AsyncSession,
173 + customer_code: Optional[str] = None,
174 + source: Optional[str] = None,
175 + include_global: bool = True,
176 +) -> CaseTemplateListResponse:
177 + """
178 + List templates, optionally filtered. Filtering rules:
179 +
180 + - ``customer_code`` provided + ``include_global=True`` (default): returns
181 + templates for that customer plus all global templates (customer_code IS
182 + NULL). This matches the natural admin-UI need: "show me what's available
183 + for customer X".
184 + - ``customer_code`` provided + ``include_global=False``: customer-scoped
185 + only.
186 + - ``source`` provided: same logic for the source dimension.
187 + - Both omitted: returns everything (typical for admin Templates view).
188 + """
189 + try:
190 + stmt = select(CaseTemplate).options(selectinload(CaseTemplate.tasks))
191 +
192 + if customer_code is not None:
193 + if include_global:
194 + stmt = stmt.where(
195 + (CaseTemplate.customer_code == customer_code) | (CaseTemplate.customer_code.is_(None)),
196 + )
197 + else:
198 + stmt = stmt.where(CaseTemplate.customer_code == customer_code)
199 +
200 + if source is not None:
201 + if include_global:
202 + stmt = stmt.where(
203 + (CaseTemplate.source == source) | (CaseTemplate.source.is_(None)),
204 + )
205 + else:
206 + stmt = stmt.where(CaseTemplate.source == source)
207 +
208 + stmt = stmt.order_by(CaseTemplate.created_at.desc())
209 +
210 + result = await session.execute(stmt)
211 + templates = result.scalars().all()
212 +
213 + return CaseTemplateListResponse(
214 + templates=[_template_to_response(t) for t in templates],
215 + success=True,
216 + message=f"Retrieved {len(templates)} template(s)",
217 + )
218 +
219 + except Exception as e:
220 + logger.error(f"Failed to list case templates: {e}")
221 + return CaseTemplateListResponse(
222 + templates=[],
223 + success=False,
224 + message=f"Failed to list case templates: {e}",
225 + )
226 +
227 +
228 +async def get_template(
229 + template_id: int,
230 + session: AsyncSession,
231 +) -> CaseTemplateOperationResponse:
232 + template = await _load_template_with_tasks(template_id, session)
233 + if template is None:
234 + return CaseTemplateOperationResponse(
235 + template=None,
236 + success=False,
237 + message=f"Template id={template_id} not found",
238 + )
239 +
240 + return CaseTemplateOperationResponse(
241 + template=_template_to_response(template),
242 + success=True,
243 + message=f"Retrieved template id={template_id}",
244 + )
245 +
246 +
247 +async def update_template(
248 + template_id: int,
249 + request: CaseTemplateUpdate,
250 + session: AsyncSession,
251 +) -> CaseTemplateOperationResponse:
252 + """Partial update of template metadata. Tasks are managed separately."""
253 + try:
254 + template = await _load_template_with_tasks(template_id, session)
255 + if template is None:
256 + return CaseTemplateOperationResponse(
257 + template=None,
258 + success=False,
259 + message=f"Template id={template_id} not found",
260 + )
261 +
262 + fields_set = request.__fields_set__
263 +
264 + if "name" in fields_set and request.name is not None:
265 + template.name = request.name
266 + if "description" in fields_set:
267 + template.description = request.description
268 + if "customer_code" in fields_set:
269 + template.customer_code = request.customer_code
270 + if "source" in fields_set:
271 + template.source = request.source
272 + if "is_default" in fields_set and request.is_default is not None:
273 + template.is_default = request.is_default
274 + if template.is_default:
275 + await _enforce_single_default(
276 + customer_code=template.customer_code,
277 + source=template.source,
278 + exclude_template_id=template.id,
279 + session=session,
280 + )
281 +
282 + template.updated_at = datetime.utcnow()
283 + session.add(template)
284 + await session.commit()
285 +
286 + refreshed = await _load_template_with_tasks(template_id, session)
287 + return CaseTemplateOperationResponse(
288 + template=_template_to_response(refreshed),
289 + success=True,
290 + message=f"Updated template id={template_id}",
291 + )
292 +
293 + except Exception as e:
294 + logger.error(f"Failed to update case template id={template_id}: {e}")
295 + await session.rollback()
296 + return CaseTemplateOperationResponse(
297 + template=None,
298 + success=False,
299 + message=f"Failed to update case template: {e}",
300 + )
301 +
302 +
303 +async def delete_template(
304 + template_id: int,
305 + session: AsyncSession,
306 +) -> CaseTemplateOperationResponse:
307 + """
308 + Delete a template and its template tasks. Existing CaseTask rows on
309 + real cases are preserved (they're snapshots) and have their
310 + ``template_task_id`` FK set to NULL implicitly via a manual update —
311 + we don't rely on cascade because the column is nullable by design.
312 + """
313 + try:
314 + template = await _load_template_with_tasks(template_id, session)
315 + if template is None:
316 + return CaseTemplateOperationResponse(
317 + template=None,
318 + success=False,
319 + message=f"Template id={template_id} not found",
320 + )
321 +
322 + snapshot = _template_to_response(template)
323 +
324 + # Null out the soft FK on any CaseTask snapshots that pointed here.
325 + # Doing this explicitly so that a future change to ON DELETE behavior
326 + # doesn't silently clobber audit trails.
327 + from app.incidents.models import CaseTask
328 +
329 + task_ids = [t.id for t in template.tasks]
330 + if task_ids:
331 + stmt = select(CaseTask).where(CaseTask.template_task_id.in_(task_ids))
332 + result = await session.execute(stmt)
333 + for case_task in result.scalars().all():
334 + case_task.template_task_id = None
335 + session.add(case_task)
336 +
337 + for task in list(template.tasks):
338 + await session.delete(task)
339 + await session.delete(template)
340 + await session.commit()
341 +
342 + return CaseTemplateOperationResponse(
343 + template=snapshot,
344 + success=True,
345 + message=f"Deleted template id={template_id}",
346 + )
347 +
348 + except Exception as e:
349 + logger.error(f"Failed to delete case template id={template_id}: {e}")
350 + await session.rollback()
351 + return CaseTemplateOperationResponse(
352 + template=None,
353 + success=False,
354 + message=f"Failed to delete case template: {e}",
355 + )
356 +
357 +
358 +# ---------------------------------------------------------------------------
359 +# Template task CRUD
360 +# ---------------------------------------------------------------------------
361 +
362 +
363 +async def add_template_task(
364 + template_id: int,
365 + request: CaseTemplateTaskCreate,
366 + session: AsyncSession,
367 +) -> CaseTemplateTaskOperationResponse:
368 + try:
369 + template = await _load_template_with_tasks(template_id, session)
370 + if template is None:
371 + return CaseTemplateTaskOperationResponse(
372 + task=None,
373 + success=False,
374 + message=f"Template id={template_id} not found",
375 + )
376 +
377 + task = CaseTemplateTask(
378 + template_id=template_id,
379 + title=request.title,
380 + description=request.description,
381 + guidelines=request.guidelines,
382 + mandatory=request.mandatory,
383 + order_index=request.order_index,
384 + )
385 + session.add(task)
386 +
387 + template.updated_at = datetime.utcnow()
388 + session.add(template)
389 +
390 + await session.commit()
391 + await session.refresh(task)
392 +
393 + return CaseTemplateTaskOperationResponse(
394 + task=_template_task_to_response(task),
395 + success=True,
396 + message=f"Added task id={task.id} to template id={template_id}",
397 + )
398 +
399 + except Exception as e:
400 + logger.error(f"Failed to add task to template id={template_id}: {e}")
401 + await session.rollback()
402 + return CaseTemplateTaskOperationResponse(
403 + task=None,
404 + success=False,
405 + message=f"Failed to add template task: {e}",
406 + )
407 +
408 +
409 +async def update_template_task(
410 + task_id: int,
411 + request: CaseTemplateTaskUpdate,
412 + session: AsyncSession,
413 +) -> CaseTemplateTaskOperationResponse:
414 + try:
415 + result = await session.execute(select(CaseTemplateTask).where(CaseTemplateTask.id == task_id))
416 + task = result.scalar_one_or_none()
417 + if task is None:
418 + return CaseTemplateTaskOperationResponse(
419 + task=None,
420 + success=False,
421 + message=f"Template task id={task_id} not found",
422 + )
423 +
424 + fields_set = request.__fields_set__
425 + if "title" in fields_set and request.title is not None:
426 + task.title = request.title
427 + if "description" in fields_set:
428 + task.description = request.description
429 + if "guidelines" in fields_set:
430 + task.guidelines = request.guidelines
431 + if "mandatory" in fields_set and request.mandatory is not None:
432 + task.mandatory = request.mandatory
433 + if "order_index" in fields_set and request.order_index is not None:
434 + task.order_index = request.order_index
435 +
436 + session.add(task)
437 +
438 + # Touch the parent template so updated_at reflects the change.
439 + template_result = await session.execute(
440 + select(CaseTemplate).where(CaseTemplate.id == task.template_id),
441 + )
442 + parent = template_result.scalar_one_or_none()
443 + if parent is not None:
444 + parent.updated_at = datetime.utcnow()
445 + session.add(parent)
446 +
447 + await session.commit()
448 + await session.refresh(task)
449 +
450 + return CaseTemplateTaskOperationResponse(
451 + task=_template_task_to_response(task),
452 + success=True,
453 + message=f"Updated template task id={task_id}",
454 + )
455 +
456 + except Exception as e:
457 + logger.error(f"Failed to update template task id={task_id}: {e}")
458 + await session.rollback()
459 + return CaseTemplateTaskOperationResponse(
460 + task=None,
461 + success=False,
462 + message=f"Failed to update template task: {e}",
463 + )
464 +
465 +
466 +async def delete_template_task(
467 + task_id: int,
468 + session: AsyncSession,
469 +) -> CaseTemplateTaskOperationResponse:
470 + try:
471 + result = await session.execute(select(CaseTemplateTask).where(CaseTemplateTask.id == task_id))
472 + task = result.scalar_one_or_none()
473 + if task is None:
474 + return CaseTemplateTaskOperationResponse(
475 + task=None,
476 + success=False,
477 + message=f"Template task id={task_id} not found",
478 + )
479 +
480 + snapshot = _template_task_to_response(task)
481 + template_id = task.template_id
482 +
483 + # Null out any CaseTask soft FKs that pointed at this template task.
484 + from app.incidents.models import CaseTask
485 +
486 + case_task_result = await session.execute(
487 + select(CaseTask).where(CaseTask.template_task_id == task_id),
488 + )
489 + for case_task in case_task_result.scalars().all():
490 + case_task.template_task_id = None
491 + session.add(case_task)
492 +
493 + await session.delete(task)
494 +
495 + template_result = await session.execute(
496 + select(CaseTemplate).where(CaseTemplate.id == template_id),
497 + )
498 + parent = template_result.scalar_one_or_none()
499 + if parent is not None:
500 + parent.updated_at = datetime.utcnow()
501 + session.add(parent)
502 +
503 + await session.commit()
504 +
505 + return CaseTemplateTaskOperationResponse(
506 + task=snapshot,
507 + success=True,
508 + message=f"Deleted template task id={task_id}",
509 + )
510 +
511 + except Exception as e:
512 + logger.error(f"Failed to delete template task id={task_id}: {e}")
513 + await session.rollback()
514 + return CaseTemplateTaskOperationResponse(
515 + task=None,
516 + success=False,
517 + message=f"Failed to delete template task: {e}",
518 + )
519 +
520 +
521 +async def reorder_template_tasks(
522 + template_id: int,
523 + ordered_task_ids: List[int],
524 + session: AsyncSession,
525 +) -> CaseTemplateOperationResponse:
526 + """
527 + Reorder tasks within a template by passing the task IDs in the desired
528 + order. Tasks not included in the list keep their existing order_index
529 + value (effectively pushed to the end of the explicit list).
530 +
531 + Validates that every passed ID belongs to the named template.
532 + """
533 + try:
534 + template = await _load_template_with_tasks(template_id, session)
535 + if template is None:
536 + return CaseTemplateOperationResponse(
537 + template=None,
538 + success=False,
539 + message=f"Template id={template_id} not found",
540 + )
541 +
542 + existing_ids = {t.id for t in template.tasks}
543 + bad = [tid for tid in ordered_task_ids if tid not in existing_ids]
544 + if bad:
545 + return CaseTemplateOperationResponse(
546 + template=None,
547 + success=False,
548 + message=f"Task ids do not belong to template id={template_id}: {bad}",
549 + )
550 +
551 + # Assign sequential indices to the explicit list so a renamed UI
552 + # drag-drop reflects clean 0..N ordering on subsequent loads.
553 + index_map = {tid: idx for idx, tid in enumerate(ordered_task_ids)}
554 + for task in template.tasks:
555 + if task.id in index_map:
556 + task.order_index = index_map[task.id]
557 + session.add(task)
558 +
559 + template.updated_at = datetime.utcnow()
560 + session.add(template)
561 +
562 + await session.commit()
563 +
564 + refreshed = await _load_template_with_tasks(template_id, session)
565 + return CaseTemplateOperationResponse(
566 + template=_template_to_response(refreshed),
567 + success=True,
568 + message=f"Reordered {len(ordered_task_ids)} task(s) on template id={template_id}",
569 + )
570 +
571 + except Exception as e:
572 + logger.error(f"Failed to reorder tasks on template id={template_id}: {e}")
573 + await session.rollback()
574 + return CaseTemplateOperationResponse(
575 + template=None,
576 + success=False,
577 + message=f"Failed to reorder template tasks: {e}",
578 + )
backend/app/incidents/services/db_operations.py
+72 -2
@@ -1417,12 +1417,38 @@ async def list_alerts(db: AsyncSession, page: int = 1, page_size: int = 25, orde
1417 return alerts_out
1418
1419
1420 -async def create_case(case: CaseCreate, db: AsyncSession) -> Case:
1420 +async def create_case(
1421 + case: CaseCreate,
1422 + db: AsyncSession,
1423 + *,
1424 + actor: Optional[str] = None,
1425 + template_id: Optional[int] = None,
1426 +) -> Case:
1427 + """
1428 + Create a Case manually (no originating alert).
1429 +
1430 + When ``template_id`` is supplied, the named CaseTemplate is applied
1431 + immediately. Otherwise tasks are NOT auto-applied — the manual path
1432 + has no source hint to pick from. Analysts can apply a template later
1433 + via ``POST /case/{id}/apply-template/{template_id}``.
1434 + """
1435 db_case = Case(**case.dict())
1436 db.add(db_case)
1437 try:
1438 await db.flush()
1439 await db.refresh(db_case)
1440 +
1441 + if template_id is not None:
1442 + from app.incidents.services.case_tasks import apply_template_to_case
1443 +
1444 + await apply_template_to_case(
1445 + case_id=db_case.id,
1446 + template_id=template_id,
1447 + actor=actor or "system",
1448 + session=db,
1449 + commit=False,
1450 + )
1451 +
1452 await db.commit()
1453 except IntegrityError:
1454 await db.rollback()
@@ -1430,7 +1456,28 @@ async def create_case(case: CaseCreate, db: AsyncSession) -> Case:
1456 return db_case
1457
1458
1433 -async def create_case_from_alert(alert_id: int, db: AsyncSession) -> Case:
1459 +async def create_case_from_alert(
1460 + alert_id: int,
1461 + db: AsyncSession,
1462 + *,
1463 + actor: Optional[str] = None,
1464 + template_id: Optional[int] = None,
1465 +) -> Case:
1466 + """
1467 + Create a Case from an Alert and (Phase 3, issue #792) auto-apply a
1468 + matching CaseTemplate.
1469 +
1470 + Template selection (when ``template_id`` is not supplied):
1471 + first-alert-wins — pick by (alert.customer_code, alert.source)
1472 + with the priority order documented in
1473 + ``app.incidents.services.case_tasks.pick_template_for_case``.
1474 + If no template matches, no tasks are created and the case is
1475 + returned unchanged.
1476 +
1477 + ``actor`` is the username performing the action; used as
1478 + ``CaseTask.created_by`` for snapshot rows. Defaults to "system" when
1479 + the caller doesn't have it (legacy code paths).
1480 + """
1481 logger.info(f"Creating case from alert {alert_id}")
1482 result = await db.execute(select(Alert).where(Alert.id == alert_id))
1483 alert = result.scalars().first()
@@ -1448,6 +1495,29 @@ async def create_case_from_alert(alert_id: int, db: AsyncSession) -> Case:
1495 try:
1496 await db.flush()
1497 await db.refresh(case)
1498 +
1499 + # Apply a case template (Phase 3, issue #792). Imported lazily to
1500 + # avoid a circular import — case_tasks pulls from this module too.
1501 + from app.incidents.services.case_tasks import apply_template_to_case
1502 + from app.incidents.services.case_tasks import auto_apply_template_for_new_case
1503 +
1504 + actor_name = actor or "system"
1505 + if template_id is not None:
1506 + await apply_template_to_case(
1507 + case_id=case.id,
1508 + template_id=template_id,
1509 + actor=actor_name,
1510 + session=db,
1511 + commit=False,
1512 + )
1513 + else:
1514 + await auto_apply_template_for_new_case(
1515 + case=case,
1516 + actor=actor_name,
1517 + session=db,
1518 + source_hint=alert.source,
1519 + )
1520 +
1521 await db.commit()
1522 except IntegrityError:
1523 await db.rollback()
backend/app/routers/incidents.py
+2
@@ -1,5 +1,6 @@
1 from fastapi import APIRouter
2
3 +from app.incidents.routes.case_templates import case_templates_router
4 from app.incidents.routes.db_operations import incidents_db_operations_router
5 from app.incidents.routes.incident_alert import incidents_alerts_router
6 from app.incidents.routes.incident_report import incidents_report_router
@@ -12,3 +13,4 @@ router.include_router(incidents_db_operations_router, prefix="/incidents/db_oper
13 router.include_router(incidents_alerts_router, prefix="/incidents/alerts", tags=["incidents-alerts"])
14 router.include_router(incidents_report_router, prefix="/incidents/report", tags=["incidents-report"])
15 router.include_router(tag_access_router, prefix="/incidents/tag_access", tags=["incidents-tag-access"])
16 +router.include_router(case_templates_router, prefix="/incidents/case_templates", tags=["incidents-case-templates"])
backend/app/version/services/version.py
+1 -1
@@ -7,7 +7,7 @@ from loguru import logger
7 from packaging.version import Version
8
9 # Current version - update this with each release
10 -CURRENT_VERSION = "0.1.59"
10 +CURRENT_VERSION = "0.1.60"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
customer-portal/src/api/endpoints/caseTemplates.ts new
+22
@@ -0,0 +1,22 @@
1 +import type { CaseEvent, CaseTask } from "@/types/caseTemplates"
2 +import type { CommonResponse } from "@/types/common"
3 +import { HttpClient } from "../httpClient"
4 +
5 +// Read-only endpoints the customer portal consumes (issue #792).
6 +// All write paths are gated to admin/analyst on the backend; this client
7 +// intentionally exposes only GET surfaces so customers see what the SOC
8 +// team is doing on their cases without being able to mutate.
9 +
10 +export default {
11 + getCaseTasks(caseId: number) {
12 + return HttpClient.get<CommonResponse<{ tasks: CaseTask[] }>>(
13 + `/incidents/db_operations/case/${caseId}/tasks`
14 + )
15 + },
16 + getCaseTimeline(caseId: number, limit = 500, offset = 0) {
17 + return HttpClient.get<CommonResponse<{ case_id: number; events: CaseEvent[] }>>(
18 + `/incidents/db_operations/case/${caseId}/timeline`,
19 + { params: { limit, offset } }
20 + )
21 + }
22 +}
customer-portal/src/api/index.ts
+2
@@ -1,6 +1,7 @@
1 import agents from "./endpoints/agents"
2 import alerts from "./endpoints/alerts"
3 import auth from "./endpoints/auth"
4 +import caseTemplates from "./endpoints/caseTemplates"
5 import cases from "./endpoints/cases"
6 import portal from "./endpoints/portal"
7 import siem from "./endpoints/siem"
@@ -10,6 +11,7 @@ export default {
11 agents,
12 alerts,
13 cases,
14 + caseTemplates,
15 siem,
16 portal
17 }
customer-portal/src/components/cases/CaseDetails/CaseDetails.vue
+8
@@ -43,6 +43,12 @@
43 @linked="handleAlertLinked"
44 />
45 </n-tab-pane>
46 + <n-tab-pane name="tasks" tab="Tasks">
47 + <CaseTasks :case-id="caseData.id" />
48 + </n-tab-pane>
49 + <n-tab-pane name="timeline" tab="Timeline">
50 + <CaseTimeline :case-id="caseData.id" />
51 + </n-tab-pane>
52 <n-tab-pane name="files" tab="Files">
53 <CaseFiles :case-id="caseData.id" />
54 </n-tab-pane>
@@ -74,6 +80,8 @@ import CaseAlerts from "./CaseAlerts.vue"
80 import CaseComments from "./CaseComments.vue"
81 import CaseFiles from "./CaseFiles.vue"
82 import CaseOverview from "./CaseOverview.vue"
83 +import CaseTasks from "./CaseTasks.vue"
84 +import CaseTimeline from "./CaseTimeline.vue"
85
86 const props = defineProps<{
87 caseId: number | null
customer-portal/src/components/cases/CaseDetails/CaseTasks.vue new
+130
@@ -0,0 +1,130 @@
1 +<template>
2 + <n-spin :show="loading">
3 + <div class="flex flex-col gap-3">
4 + <div class="flex items-center gap-3 text-sm">
5 + <Chip :value="tasks.length" label="tasks" :bordered="false" />
6 + <Chip v-if="totalDone > 0" :value="totalDone" label="done" type="success" :bordered="false" />
7 + <Chip
8 + v-if="mandatoryIncomplete > 0"
9 + :bordered="false"
10 + :value="mandatoryIncomplete"
11 + label="mandatory incomplete"
12 + type="warning"
13 + />
14 + </div>
15 +
16 + <p class="text-xs">Read-only view of investigation tasks performed by the SOC team on this case.</p>
17 +
18 + <div v-if="tasks.length" class="flex flex-col gap-3">
19 + <CardEntity
20 + v-for="task in tasks"
21 + :key="task.id"
22 + :status="
23 + task.status === 'DONE' ? 'success' : task.status === 'NOT_NECESSARY' ? 'warning' : undefined
24 + "
25 + embedded
26 + >
27 + <template #header-main>
28 + <div class="flex flex-wrap items-center gap-3">
29 + <span class="text-default font-sans text-base">
30 + {{ task.title }}
31 + </span>
32 +
33 + <n-tag v-if="task.mandatory" :bordered="false" type="error" size="small">mandatory</n-tag>
34 + <n-tag v-if="task.template_task_id == null" :bordered="false" type="default" size="small">
35 + custom
36 + </n-tag>
37 + </div>
38 + </template>
39 + <template #header-extra>
40 + <Chip :value="statusLabel(task.status)" :type="statusTagType(task.status)" :bordered="false" />
41 + </template>
42 + <template #default>
43 + <div class="flex flex-col gap-3">
44 + <p v-if="task.description" class="text-secondary text-sm">{{ task.description }}</p>
45 +
46 + <details v-if="task.guidelines" class="text-sm">
47 + <summary class="cursor-pointer font-medium">Guidelines</summary>
48 + <p class="text-secondary mt-1 whitespace-pre-line">{{ task.guidelines }}</p>
49 + </details>
50 + </div>
51 + </template>
52 + <template v-if="task.evidence_comment" #main-extra>
53 + <div class="flex flex-col gap-1">
54 + <div class="text-secondary text-xs uppercase">Notes from analyst</div>
55 + <p class="text-sm whitespace-pre-line">
56 + {{ task.evidence_comment }}
57 + </p>
58 + </div>
59 + </template>
60 + <template #footer>
61 + <div class="flex flex-wrap items-center justify-between gap-2">
62 + <div class="text-secondary flex flex-wrap gap-x-4 gap-y-1 text-sm">
63 + <span v-if="task.completed_by">
64 + {{ task.status === "DONE" ? "Completed" : "Marked" }} by
65 + <strong>{{ task.completed_by }}</strong>
66 + <template v-if="task.completed_at">
67 + · {{ formatDate(task.completed_at, dFormats.datetime) }}
68 + </template>
69 + </span>
70 + <span v-else>
71 + Created by
72 + <strong>{{ task.created_by }}</strong>
73 + </span>
74 + </div>
75 +
76 + <div></div>
77 + </div>
78 + </template>
79 + </CardEntity>
80 + </div>
81 + <n-empty v-else-if="!loading" description="No tasks on this case yet" class="h-32 justify-center" />
82 + </div>
83 + </n-spin>
84 +</template>
85 +
86 +<script setup lang="ts">
87 +import type { CaseTask, CaseTaskStatus } from "@/types/caseTemplates"
88 +import type { ApiError } from "@/types/common"
89 +import { NEmpty, NSpin, NTag, useMessage } from "naive-ui"
90 +import { computed, ref, watch } from "vue"
91 +import Api from "@/api"
92 +import CardEntity from "@/components/common/cards/CardEntity.vue"
93 +import Chip from "@/components/common/Chip.vue"
94 +import { useSettingsStore } from "@/stores/settings"
95 +import { getApiErrorMessage } from "@/utils"
96 +import { formatDate } from "@/utils/format"
97 +
98 +const props = defineProps<{
99 + caseId: number
100 +}>()
101 +
102 +const message = useMessage()
103 +const tasks = ref<CaseTask[]>([])
104 +const loading = ref(false)
105 +const dFormats = useSettingsStore().dateFormat
106 +const totalDone = computed(() => tasks.value.filter(t => t.status === "DONE").length)
107 +const mandatoryIncomplete = computed(() => tasks.value.filter(t => t.mandatory && t.status !== "DONE").length)
108 +
109 +function statusLabel(s: CaseTaskStatus): string {
110 + return s === "TODO" ? "To do" : s === "DONE" ? "Done" : "Not necessary"
111 +}
112 +
113 +function statusTagType(s: CaseTaskStatus) {
114 + return s === "DONE" ? "success" : s === "NOT_NECESSARY" ? "warning" : "default"
115 +}
116 +
117 +async function fetchTasks() {
118 + loading.value = true
119 + try {
120 + const res = await Api.caseTemplates.getCaseTasks(props.caseId)
121 + tasks.value = res.data.tasks ?? []
122 + } catch (err) {
123 + message.error(getApiErrorMessage(err as ApiError))
124 + } finally {
125 + loading.value = false
126 + }
127 +}
128 +
129 +watch(() => props.caseId, fetchTasks, { immediate: true })
130 +</script>
customer-portal/src/components/cases/CaseDetails/CaseTimeline.vue new
+178
@@ -0,0 +1,178 @@
1 +<template>
2 + <n-spin :show="loading">
3 + <div class="flex flex-col gap-3">
4 + <div class="flex items-center justify-between">
5 + <Chip :value="events.length" label="events" :bordered="false" />
6 +
7 + <n-button size="small" secondary @click="fetchTimeline">
8 + <template #icon><Icon name="carbon:renew" /></template>
9 + Refresh
10 + </n-button>
11 + </div>
12 +
13 + <p class="text-secondary text-xs">Read-only audit log of investigation activity on this case.</p>
14 +
15 + <n-timeline v-if="events.length">
16 + <n-timeline-item
17 + v-for="event in events"
18 + :key="event.id"
19 + :type="timelineType(event)"
20 + :time="formatDate(event.timestamp, dFormats.datetimesec).toString()"
21 + >
22 + <template #header>
23 + <div class="flex flex-wrap items-center gap-2">
24 + <Icon :name="iconFor(event)" :size="16" class="text-secondary" />
25 + <span class="font-medium">{{ summary(event) }}</span>
26 + <n-tag size="tiny" :bordered="false">{{ event.actor }}</n-tag>
27 + </div>
28 + </template>
29 + <div v-if="hasDetail(event)" class="text-secondary mt-1 text-sm">
30 + <component :is="renderDetail(event)" />
31 + </div>
32 + </n-timeline-item>
33 + </n-timeline>
34 + <n-empty v-else-if="!loading" description="No timeline events yet" class="h-32 justify-center" />
35 + </div>
36 + </n-spin>
37 +</template>
38 +
39 +<script setup lang="ts">
40 +import type { CaseEvent } from "@/types/caseTemplates"
41 +import type { ApiError } from "@/types/common"
42 +import { NButton, NEmpty, NSpin, NTag, NTimeline, NTimelineItem, useMessage } from "naive-ui"
43 +import { h, ref, watch } from "vue"
44 +import Api from "@/api"
45 +import Chip from "@/components/common/Chip.vue"
46 +import Icon from "@/components/common/Icon.vue"
47 +import { useSettingsStore } from "@/stores/settings"
48 +import { getApiErrorMessage } from "@/utils"
49 +import { formatDate } from "@/utils/format"
50 +
51 +const props = defineProps<{
52 + caseId: number
53 +}>()
54 +
55 +const dFormats = useSettingsStore().dateFormat
56 +
57 +const message = useMessage()
58 +const events = ref<CaseEvent[]>([])
59 +const loading = ref(false)
60 +
61 +async function fetchTimeline() {
62 + loading.value = true
63 + try {
64 + const res = await Api.caseTemplates.getCaseTimeline(props.caseId)
65 + events.value = res.data.events ?? []
66 + } catch (err) {
67 + message.error(getApiErrorMessage(err as ApiError))
68 + } finally {
69 + loading.value = false
70 + }
71 +}
72 +
73 +function summary(event: CaseEvent): string {
74 + const p = (event.payload || {}) as Record<string, any>
75 + switch (event.event_type) {
76 + case "case_created":
77 + return p.source === "from_alert" ? `Case created from alert #${p.alert_id}` : "Case created"
78 + case "case_status_changed":
79 + return p.forced
80 + ? `Status forced from ${p.from ?? "—"} to ${p.to} (mandatory tasks bypassed)`
81 + : `Status changed from ${p.from ?? "—"} to ${p.to}`
82 + case "case_assigned":
83 + return p.from
84 + ? `Reassigned from ${p.from} to ${p.to ?? "unassigned"}`
85 + : `Assigned to ${p.to ?? "unassigned"}`
86 + case "case_escalated":
87 + return p.escalated ? "Case escalated" : "Case de-escalated"
88 + case "alert_linked":
89 + return p.alert_ids ? `${p.alert_ids.length} alert(s) linked to case` : `Alert #${p.alert_id} linked`
90 + case "alert_unlinked":
91 + return `Alert #${p.alert_id} unlinked`
92 + case "comment_added":
93 + return "Comment added"
94 + case "template_applied":
95 + return `Template applied: ${p.template_name ?? `#${p.template_id}`} (${p.tasks_added ?? 0} task${p.tasks_added === 1 ? "" : "s"})`
96 + case "task_added":
97 + return `Task added: ${p.title ?? `#${p.task_id}`}${p.mandatory ? " (mandatory)" : ""}`
98 + case "task_status_changed":
99 + return `Task ${p.title ?? `#${p.task_id}`}: ${p.from_status ?? "—"} → ${p.to_status ?? "—"}`
100 + case "task_commented":
101 + return `Notes added on task: ${p.title ?? `#${p.task_id}`}`
102 + default:
103 + return String(event.event_type).replace(/_/g, " ")
104 + }
105 +}
106 +
107 +function timelineType(event: CaseEvent): "default" | "success" | "info" | "warning" | "error" {
108 + const p = (event.payload || {}) as Record<string, any>
109 + switch (event.event_type) {
110 + case "case_created":
111 + case "alert_linked":
112 + case "template_applied":
113 + return "info"
114 + case "case_status_changed":
115 + return p.to === "CLOSED" ? "success" : p.to === "OPEN" ? "info" : "warning"
116 + case "task_status_changed":
117 + return p.to_status === "DONE" ? "success" : p.to_status === "NOT_NECESSARY" ? "warning" : "default"
118 + case "case_escalated":
119 + return p.escalated ? "warning" : "default"
120 + case "alert_unlinked":
121 + return "warning"
122 + default:
123 + return "default"
124 + }
125 +}
126 +
127 +function iconFor(event: CaseEvent): string {
128 + switch (event.event_type) {
129 + case "case_created":
130 + return "carbon:document-add"
131 + case "case_status_changed":
132 + return "carbon:flow-modeler"
133 + case "case_assigned":
134 + return "carbon:user-avatar-filled-alt"
135 + case "case_escalated":
136 + return "carbon:warning-alt"
137 + case "alert_linked":
138 + return "carbon:link"
139 + case "alert_unlinked":
140 + return "carbon:unlink"
141 + case "comment_added":
142 + return "carbon:chat"
143 + case "template_applied":
144 + return "carbon:flow"
145 + case "task_added":
146 + return "carbon:add-alt"
147 + case "task_status_changed":
148 + return "carbon:checkmark"
149 + case "task_commented":
150 + return "carbon:notebook"
151 + default:
152 + return "carbon:circle-dash"
153 + }
154 +}
155 +
156 +function hasDetail(event: CaseEvent): boolean {
157 + const p = (event.payload || {}) as Record<string, any>
158 + return !!(p.snippet || (event.event_type === "alert_linked" && p.alert_ids))
159 +}
160 +
161 +function renderDetail(event: CaseEvent) {
162 + const p = (event.payload || {}) as Record<string, any>
163 + if ((event.event_type === "comment_added" || event.event_type === "task_commented") && p.snippet) {
164 + return () => h("blockquote", { class: "border-border mt-1 border-l-4 pl-3 italic" }, String(p.snippet))
165 + }
166 + if (event.event_type === "alert_linked" && Array.isArray(p.alert_ids)) {
167 + return () =>
168 + h(
169 + "span",
170 + { class: "text-tertiary" },
171 + `Alerts: ${(p.alert_ids as number[]).map((n: number) => `#${n}`).join(", ")}`
172 + )
173 + }
174 + return () => h("span")
175 +}
176 +
177 +watch(() => props.caseId, fetchTimeline, { immediate: true })
178 +</script>
customer-portal/src/types/caseTemplates.ts new
+45
@@ -0,0 +1,45 @@
1 +// Mirrors the read-only fields that the customer portal consumes from
2 +// backend/app/incidents/schema/case_templates.py. Customers never write
3 +// these — only view.
4 +
5 +export type CaseTaskStatus = "TODO" | "DONE" | "NOT_NECESSARY"
6 +
7 +export type CaseEventType =
8 + | "case_created"
9 + | "case_status_changed"
10 + | "case_assigned"
11 + | "case_escalated"
12 + | "alert_linked"
13 + | "alert_unlinked"
14 + | "comment_added"
15 + | "template_applied"
16 + | "task_added"
17 + | "task_status_changed"
18 + | "task_commented"
19 +
20 +export interface CaseTask {
21 + id: number
22 + case_id: number
23 + template_task_id?: number | null
24 + title: string
25 + description?: string | null
26 + guidelines?: string | null
27 + mandatory: boolean
28 + order_index: number
29 + status: CaseTaskStatus
30 + evidence_comment?: string | null
31 + completed_by?: string | null
32 + completed_at?: string | null
33 + created_by: string
34 + created_at: string
35 + updated_at: string
36 +}
37 +
38 +export interface CaseEvent {
39 + id: number
40 + case_id: number
41 + event_type: CaseEventType
42 + actor: string
43 + timestamp: string
44 + payload?: Record<string, unknown> | null
45 +}
docs/user/ui/incident-case-templates.md new
+335
@@ -0,0 +1,335 @@
1 +---
2 +title: Case templates
3 +description: Reusable investigation playbooks with predefined tasks and timeline auditing for cases.
4 +---
5 +
6 +# Case templates
7 +
8 +**Menu:** Incident Management → Case Templates
9 +
10 +**Best for:** Admin / Analyst (template ownership is restricted to these roles)
11 +
12 +Case templates are reusable **investigation playbooks** you attach to cases. Each template defines a set of tasks (with optional guidelines) that get snapshot-copied onto a new case when the template matches. Customers see the resulting tasks read-only on their portal so they know what's being worked on.
13 +
14 +Templates address three real problems:
15 +
16 +- **Investigation consistency** — every case for a given alert source follows the same checklist
17 +- **Auditability** — task status changes, comments, and case mutations all land in a per-case timeline
18 +- **Onboarding** — new analysts don't have to memorize the playbook; the case carries it
19 +
20 +---
21 +
22 +## Where templates apply
23 +
24 +A template has three optional scoping fields:
25 +
26 +| Field | Effect | Example |
27 +|---|---|---|
28 +| `customer_code` | Restricts the template to one customer. Empty = global. | `ACME` |
29 +| `source` | Restricts the template to one alert source. Empty = any source. | `wazuh` |
30 +| `is_default` | This is the fallback template within its (customer_code, source) scope. | true / false |
31 +
32 +When a case is created **from an alert**, the backend picks the most specific matching template using this priority:
33 +
34 +1. `customer_code` + `source` exact match
35 +2. `customer_code` only (source IS NULL)
36 +3. `source` only (customer_code IS NULL)
37 +4. Global default (`is_default=true` with both NULL)
38 +
39 +Each step short-circuits the next on first match. Within a step, ties are broken by `is_default` first, then by most-recently-created.
40 +
41 +> **First-alert-wins.** When a case is created from an alert, the template is picked from that alert's `(customer_code, source)`. If you later link more alerts to the case, additional templates are **not** auto-applied — analysts can manually apply more via the case's Tasks tab if needed.
42 +
43 +---
44 +
45 +## Authoring a template
46 +
47 +**Menu:** Incident Management → Case Templates → **New template**
48 +
49 +| Field | Required | Notes |
50 +|---|---|---|
51 +| Name | yes | Short, recognizable. Shown in the picker on case creation. |
52 +| Description | no | Free-form context for analysts. |
53 +| Customer code | no | Empty = global. |
54 +| Alert source | no | Empty = any source. |
55 +| Default for scope | no | Marks this as the fallback within its (customer, source) pair. Only one default per scope; activating one auto-demotes any other. |
56 +| Tasks | yes | At least one task. Each task has title, description, guidelines, mandatory toggle, and order. |
57 +
58 +### Task fields
59 +
60 +| Field | Effect |
61 +|---|---|
62 +| Title | Short statement of what to do (e.g., "Identify affected assets") |
63 +| Description | Longer explanation — what success looks like |
64 +| Guidelines | Step-by-step or links to runbooks. Rendered collapsibly under each task on the case Tasks tab. |
65 +| Mandatory | If true, **NOT_NECESSARY** is rejected and closing the case with this task incomplete fires a soft warning. |
66 +| Order | Drag arrows in the editor reorder tasks; lower order_index renders first. |
67 +
68 +> Edits to a template **do not** mutate task snapshots already attached to real cases. Each `CaseTask` row is a copy made at template-application time. This way historical investigations stay locked to the template version that was in effect when they were opened.
69 +
70 +---
71 +
72 +## Tasks on a real case
73 +
74 +**Menu:** any case → **Tasks** tab
75 +
76 +Analysts see:
77 +
78 +- The full task list (status, evidence notes, completion attribution)
79 +- A status dropdown per task: **TODO**, **DONE**, **NOT_NECESSARY** (last is greyed out for mandatory tasks)
80 +- An evidence textarea where you can paste logs, command output, screenshots-as-text, or links
81 +- "Add task" — for one-off custom tasks added during the investigation
82 +- "Apply template" — to layer another template's tasks onto the case (e.g., add an EDR-specific checklist after a Wazuh template was already applied)
83 +
84 +Customers see the same list **read-only** on the customer portal. They cannot change status, edit evidence, or add tasks.
85 +
86 +---
87 +
88 +## Soft warning on close
89 +
90 +When you try to close a case where one or more **mandatory** tasks are not marked DONE, a confirmation modal appears listing the incomplete tasks. You can:
91 +
92 +- **Cancel** — closes the modal, leaves the case in its current status
93 +- **Close anyway** — closes the case and records `forced=true` in the timeline so the override is auditable
94 +
95 +The intent is to remind, not block. Mandatory + soft warning gives consistency without forcing analysts to lie ("I marked it done so I could close it") — the override is captured honestly in the audit trail.
96 +
97 +---
98 +
99 +## Timeline tab
100 +
101 +**Menu:** any case → **Timeline** tab
102 +
103 +Append-only audit log of meaningful case mutations. One row per:
104 +
105 +- Case created (manual or from alert)
106 +- Status change (with the `forced=true` flag when the soft warning was bypassed)
107 +- Assignment, escalation
108 +- Alert link / unlink (single or bulk)
109 +- Comment added (with a short snippet preview)
110 +- Template applied
111 +- Task added (template-derived or custom)
112 +- Task status change
113 +- Task evidence comment
114 +
115 +Customer portal shows the same timeline read-only.
116 +
117 +---
118 +
119 +## Examples
120 +
121 +### Wazuh global default
122 +
123 +```
124 +Name: Wazuh — Default
125 +Source: wazuh
126 +Customer: (empty — global)
127 +Default: yes
128 +Tasks:
129 + 1. Triage alert (mandatory)
130 + 2. Identify affected assets (mandatory, with guidelines)
131 + 3. Check Wazuh agent for related events (mandatory)
132 + 4. Document findings (optional)
133 + 5. Notify customer (optional, NOT_NECESSARY allowed)
134 +```
135 +
136 +Result: any case created from a Wazuh alert (regardless of customer) gets these five tasks pre-populated. Closing the case requires steps 1–3 to be DONE or the soft warning fires.
137 +
138 +### Customer-specific override
139 +
140 +```
141 +Name: ACME — Wazuh
142 +Source: wazuh
143 +Customer: ACME
144 +Default: yes
145 +Tasks:
146 + 1. Triage alert (mandatory)
147 + 2. Identify affected assets (mandatory)
148 + 3. Check ACME-specific runbook in wiki (mandatory, guidelines link)
149 + 4. Page on-call if business-hours (mandatory)
150 +```
151 +
152 +Because `(customer=ACME, source=wazuh)` is more specific than `(customer=any, source=wazuh)`, ACME's Wazuh cases get this template instead of the global one.
153 +
154 +### EDR-specific addon
155 +
156 +```
157 +Name: CrowdStrike — Investigation
158 +Source: crowdstrike
159 +Customer: (empty)
160 +Default: no
161 +Tasks:
162 + 1. Pull process tree from EDR
163 + 2. Identify network connections
164 + 3. Collect memory dump if hash unknown (guidelines: link to runbook)
165 +```
166 +
167 +Marked non-default. Auto-applies on create-from-CrowdStrike-alert. Analysts can also manually apply this template to a Wazuh case mid-investigation if EDR work becomes relevant.
168 +
169 +---
170 +
171 +## Permissions summary
172 +
173 +| Capability | Admin | Analyst | Customer User |
174 +|---|---|---|---|
175 +| Manage templates (create / edit / delete) | ✅ | ✅ | — |
176 +| Apply template to case | ✅ | ✅ | — |
177 +| Add custom case task | ✅ | ✅ | — |
178 +| Update case task status / evidence | ✅ | ✅ | — |
179 +| Delete case task | ✅ | ✅ | — |
180 +| View case tasks | ✅ | ✅ | ✅ (read-only) |
181 +| View case timeline | ✅ | ✅ | ✅ (read-only) |
182 +| Close case with incomplete mandatory tasks | ✅ | ✅ | — |
183 +
184 +---
185 +
186 +## How to use templates well
187 +
188 +The mechanics above describe what templates *can* do. This section is about *how to actually use them* so they accelerate investigations instead of cluttering them.
189 +
190 +### Start with one global default per source, then layer
191 +
192 +Resist the urge to author per-customer templates on day one. The path that scales:
193 +
194 +1. **Pick your top 2–3 alert sources** (Wazuh, CrowdStrike, Velociraptor — whatever drives the most cases). Author one **global default** per source. Each should have 3–6 mandatory tasks that capture the universal triage flow, plus 2–4 optional tasks for common follow-ups.
195 +2. **Run for two weeks.** Watch which tasks consistently get marked NOT_NECESSARY. Watch what custom tasks analysts add (the Tasks tab "Add task" button — those are unmet template needs). Track close-with-force events in the timeline.
196 +3. **Tune the global default** based on what you saw. Demote noisy mandatory tasks to optional, promote frequently-added custom tasks into the template.
197 +4. **Only then** start adding customer-specific overrides for the customers that actually have a different runbook (PCI environments, regulated industries, customer-specific evidence requirements, etc.).
198 +
199 +You can always create more templates. Removing them later is harder once analysts have memorized the workflow.
200 +
201 +### Mandatory discipline — what *should* block close
202 +
203 +The soft warning fires on close when a mandatory task isn't DONE. The override is recorded as `forced=true` in the timeline.
204 +
205 +A task should be **mandatory** only if:
206 +
207 +- Skipping it would leave you unable to answer "what did you actually find?" later
208 +- Skipping it would fail a compliance/audit review
209 +- Skipping it would leave the customer with an unsupported claim ("the alert was benign")
210 +
211 +A task should be **optional** if:
212 +
213 +- It only applies in some scenarios (e.g., "Pull memory dump" — only matters if hash is unknown)
214 +- It's nice-to-have but the case can close honestly without it
215 +- It's expensive (analyst time, customer time) and not always justified
216 +
217 +> **Anti-pattern:** marking everything mandatory. Analysts will start force-closing routinely, the timeline fills with `forced=true`, and the soft warning becomes background noise instead of a real safety net.
218 +
219 +### Use guidelines as the runbook quick-reference
220 +
221 +The `guidelines` field renders as a collapsible panel under each task. Treat it as the **5-second runbook** — what the analyst needs without leaving the case page.
222 +
223 +Good guidelines content:
224 +
225 +- 1–3 sentences of "what success looks like for this task"
226 +- A direct link to the deeper runbook in your wiki/SharePoint/Confluence
227 +- 2–3 bullet hints if there's a common gotcha
228 +- Actual command snippets if the task involves running something
229 +
230 +```
231 +Bad: "Investigate the alert."
232 +Good: "Confirm the alert isn't a known false positive (check our exception list
233 + at <wiki link>). If new, pull the matching events from the last 24h via
234 + Graylog query: source.ip:X.X.X.X AND event.action:authentication_failure"
235 +```
236 +
237 +### Evidence comments are the compliance trail
238 +
239 +Every task has an evidence comment textarea. The customer-portal user sees this read-only.
240 +
241 +What to put there:
242 +
243 +- **Logs / command output** — paste the actual snippet, don't just describe it
244 +- **Reference IDs** — Jira ticket, ServiceNow change number, Velociraptor hunt ID, Graylog query URL
245 +- **Decisions and reasoning** — "marked NOT_NECESSARY because the affected host is decommissioned, see asset CMDB"
246 +- **Customer communication** — "notified customer at 14:32 EST via portal comment"
247 +
248 +What *not* to put there:
249 +
250 +- Sensitive data the customer shouldn't see (the customer-portal will surface it)
251 +- Internal-team chatter (use case Comments tab for that — also visible to customer but framed as conversation)
252 +
253 +### Reorder for natural investigation flow
254 +
255 +The order_index dictates display order on the case Tasks tab. Sequence tasks the way an analyst actually works the case:
256 +
257 +1. Triage / scope (is this real? how big?)
258 +2. Identify (who/what/where)
259 +3. Investigate (logs, processes, network)
260 +4. Decide (true positive / false positive / inconclusive)
261 +5. Act (contain / notify / document)
262 +6. Close (notify customer, retro)
263 +
264 +Tasks higher in the list should be cheaper and faster — get to a triage decision early so the analyst doesn't burn 30 minutes investigating before realizing it's a known false positive.
265 +
266 +### The two-template pattern: source + capability
267 +
268 +Many investigations are "Wazuh detection that needs EDR follow-up". Rather than authoring one giant Wazuh-with-EDR template, use two:
269 +
270 +- `Wazuh — Default` (auto-applies via source match)
271 +- `CrowdStrike — Investigation` (manually layered when EDR work is needed)
272 +
273 +Analyst flow:
274 +1. Case auto-opens from Wazuh alert with the Wazuh template
275 +2. Initial triage reveals lateral movement → analyst clicks "Apply template" and picks the CrowdStrike one
276 +3. Both templates' tasks are now on the case, separately tracked
277 +
278 +This keeps each template focused (and reusable for non-co-occurring scenarios) and lets you grow the library without exploding the matrix.
279 +
280 +### Custom-task adds as a feedback loop
281 +
282 +The "Add task" button on the case Tasks tab is for one-off needs that don't fit any template. But it's also a *signal*. If you find:
283 +
284 +- The same custom task being added across many cases → promote to a template task
285 +- A custom task being added on cases for one specific customer → maybe that customer needs an override template
286 +- Custom tasks consistently appearing *before* the template's first task → reorder so the template starts where investigations actually start
287 +
288 +Schedule a 30-minute monthly template review and look at recent custom tasks. The data is in the timeline (`task_added` events with `source: "custom"`).
289 +
290 +### Timeline as compliance + handoff tool
291 +
292 +The timeline is more than an audit log — it's the case's **narrative**. When you hand a case to another analyst (shift change, escalation), they should be able to read the timeline top-to-bottom and understand:
293 +
294 +- What's been done
295 +- What's left
296 +- What the analyst was thinking (via task evidence comments and case Comments)
297 +
298 +For compliance reviews, the timeline answers: "Was the procedure followed? When? By whom? If it wasn't, was the deviation documented?" The `forced=true` flag on close, the evidence comments on each task, and the actor/timestamp on each event give you that story without manually reconstructing it.
299 +
300 +### Customer-portal as transparency tool
301 +
302 +Customers see Tasks + Timeline read-only. Use this deliberately:
303 +
304 +- **Mandatory tasks signal effort.** A customer seeing 5 mandatory tasks completed with evidence understands their alert was investigated, not just dismissed.
305 +- **Status changes signal velocity.** OPEN → IN_PROGRESS → CLOSED with reasonable timestamps in the timeline shows responsiveness.
306 +- **Evidence comments signal substance.** A task marked DONE with no evidence looks like a checkbox tick. A task with `"Pulled process tree from EDR, no suspicious children. See Velociraptor hunt vh-1234"` shows real work.
307 +
308 +If you don't want the customer to see a particular detail, put it in the case Comments tab (still visible) framed as analyst-to-analyst conversation, or in your internal wiki and reference the link from the evidence comment.
309 +
310 +### Quarterly template review checklist
311 +
312 +Set a recurring 30-minute meeting with your analyst leads:
313 +
314 +- [ ] What's the close-with-force rate per template? (Anything > 20% suggests a mandatory task is wrong.)
315 +- [ ] What custom tasks were added this quarter? Any patterns?
316 +- [ ] Are there customers consistently force-closing with the global template? Time for an override.
317 +- [ ] Are there templates with > 80% NOT_NECESSARY on a specific task? Demote it.
318 +- [ ] Have any new alert sources been onboarded that need their own template?
319 +- [ ] Any guideline links that 404? (Wiki rot is real.)
320 +
321 +---
322 +
323 +## Common gotchas
324 +
325 +### "I edited the template but the existing case didn't change"
326 +Expected — task rows on a case are snapshots, not live references. New cases will pick up the edits; old ones won't.
327 +
328 +### "I deleted a template and the case Tasks tab is empty"
329 +Not expected. Template deletion preserves CaseTask snapshots and only nulls the soft `template_task_id` link. If tasks vanished, file an issue.
330 +
331 +### "The wrong template auto-applied to my case"
332 +Check the priority order. A more specific match (customer + source) always wins over a less specific one. If you have multiple defaults at the same scope, the most-recently-created wins — promote the one you want to default and the others get auto-demoted.
333 +
334 +### "The customer portal shows a task they shouldn't see"
335 +Customer portal scoping uses the case's `customer_code`. If a case is mis-scoped, the tasks follow it. Fix the case's customer_code; the tasks come along for the ride.
docs/user/ui/incident-management.md
+1
@@ -11,5 +11,6 @@ Incident Management is where analysts spend most of their time.
11 - **Sources**: define/organize alert sources
12 - **Alerts**: triage queue
13 - **Cases**: investigation lifecycle
14 +- **Case Templates**: reusable investigation playbooks (admin / analyst) — see [Case templates](incident-case-templates.md)
15
16 ![Incident Alerts](../../assets/ui/incident-alerts.png)
frontend/package.json
+13 -13
@@ -3,7 +3,7 @@
3 "type": "module",
4 "version": "1.0.0",
5 "private": true,
6 - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
6 + "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
7 "engines": {
8 "node": ">=18.0.0"
9 },
@@ -52,14 +52,14 @@
52 "@types/codemirror": "^5.60.17",
53 "@vueuse/core": "^14.2.1",
54 "@vueuse/motion": "^3.0.3",
55 - "axios": "^1.15.1",
55 + "axios": "^1.15.2",
56 "bytes": "^3.1.2",
57 "codemirror": "~6.0.2",
58 "colord": "^2.9.3",
59 "dayjs": "^1.11.20",
60 "detect-touch-device": "^1.1.6",
61 "echarts": "^6.0.0",
62 - "fast-xml-parser": "^5.7.1",
62 + "fast-xml-parser": "^5.7.2",
63 "file-saver": "^2.0.5",
64 "html-entities": "^2.6.0",
65 "jose": "^6.2.2",
@@ -75,12 +75,12 @@
75 "shiki": "^4.0.2",
76 "thememirror": "^2.0.1",
77 "validator": "^13.15.35",
78 - "vue": "^3.5.32",
78 + "vue": "^3.5.33",
79 "vue-advanced-cropper": "^2.8.9",
80 "vue-codemirror": "^6.1.1",
81 "vue-highlight-words": "^3.0.1",
82 - "vue-i18n": "^11.3.2",
83 - "vue-router": "^5.0.4",
82 + "vue-i18n": "^11.4.0",
83 + "vue-router": "^5.0.6",
84 "vue-sjv": "^0.0.6",
85 "vue3-apexcharts": "^1.11.1",
86 "vue3-marquee": "^4.2.2",
@@ -97,7 +97,7 @@
97 "@antfu/eslint-config": "~8.2.0",
98 "@clack/prompts": "^1.2.0",
99 "@iconify/vue": "^5.0.0",
100 - "@tailwindcss/vite": "^4.2.2",
100 + "@tailwindcss/vite": "^4.2.4",
101 "@tsconfig/node20": "^20.1.9",
102 "@types/bytes": "^3.1.5",
103 "@types/file-saver": "^2.0.7",
@@ -109,9 +109,9 @@
109 "@types/validator": "^13.15.10",
110 "@vitejs/plugin-vue": "^6.0.6",
111 "@vitejs/plugin-vue-jsx": "^5.1.5",
112 - "@vue/test-utils": "^2.4.6",
112 + "@vue/test-utils": "^2.4.9",
113 "@vue/tsconfig": "~0.9.1",
114 - "baseline-browser-mapping": "^2.10.20",
114 + "baseline-browser-mapping": "^2.10.23",
115 "depcheck": "^1.4.7",
116 "eslint": "~10.2.1",
117 "flourite": "^1.3.0",
@@ -120,10 +120,10 @@
120 "jsdom": "^29.0.2",
121 "npm-run-all2": "^8.0.4",
122 "prettier": "^3.8.3",
123 - "prettier-plugin-tailwindcss": "^0.7.2",
123 + "prettier-plugin-tailwindcss": "^0.7.3",
124 "sass": "^1.99.0",
125 "start-server-and-test": "^3.0.2",
126 - "tailwindcss": "^4.2.2",
126 + "tailwindcss": "^4.2.4",
127 "taze": "^19.11.0",
128 "type-fest": "^5.6.0",
129 "typescript": "~5.9.3",
@@ -132,7 +132,7 @@
132 "vite-plugin-inspect": "^11.3.3",
133 "vite-plugin-vue-devtools": "^8.1.1",
134 "vite-svg-loader": "^5.1.1",
135 - "vitest": "^4.1.4",
135 + "vitest": "^4.1.5",
136 "vue-tsc": "^3.2.7"
137 },
138 "pnpm": {
@@ -148,4 +148,4 @@
148 "unrs-resolver"
149 ]
150 }
151 -}
\ No newline at end of file
151 +}
frontend/pnpm-lock.yaml
+368 -310
@@ -31,7 +31,7 @@ importers:
31 version: 6.41.1
32 '@f3ve/vue-markdown-it':
33 specifier: ^0.2.3
34 - version: 0.2.3(vue@3.5.32(typescript@5.9.3))
34 + version: 0.2.3(vue@3.5.33(typescript@5.9.3))
35 '@fontsource/jetbrains-mono':
36 specifier: ^5.2.8
37 version: 5.2.8
@@ -55,13 +55,13 @@ importers:
55 version: 5.60.17
56 '@vueuse/core':
57 specifier: ^14.2.1
58 - version: 14.2.1(vue@3.5.32(typescript@5.9.3))
58 + version: 14.2.1(vue@3.5.33(typescript@5.9.3))
59 '@vueuse/motion':
60 specifier: ^3.0.3
61 - version: 3.0.3(vue@3.5.32(typescript@5.9.3))
61 + version: 3.0.3(vue@3.5.33(typescript@5.9.3))
62 axios:
63 - specifier: ^1.15.1
64 - version: 1.15.1(debug@4.4.3)
63 + specifier: ^1.15.2
64 + version: 1.15.2(debug@4.4.3)
65 bytes:
66 specifier: ^3.1.2
67 version: 3.1.2
@@ -81,8 +81,8 @@ importers:
81 specifier: ^6.0.0
82 version: 6.0.0
83 fast-xml-parser:
84 - specifier: ^5.7.1
85 - version: 5.7.1
84 + specifier: ^5.7.2
85 + version: 5.7.2
86 file-saver:
87 specifier: ^2.0.5
88 version: 2.0.5
@@ -103,7 +103,7 @@ importers:
103 version: 3.0.1
104 naive-ui:
105 specifier: ^2.44.1
106 - version: 2.44.1(vue@3.5.32(typescript@5.9.3))
106 + version: 2.44.1(vue@3.5.33(typescript@5.9.3))
107 nanoid:
108 specifier: ^5.1.9
109 version: 5.1.9
@@ -112,10 +112,10 @@ importers:
112 version: 5.3.0
113 pinia:
114 specifier: ^3.0.4
115 - version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
115 + version: 3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3))
116 pinia-plugin-persistedstate:
117 specifier: ^4.7.1
118 - version: 4.7.1(@nuxt/kit@3.21.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))
118 + version: 4.7.1(@nuxt/kit@3.21.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3)))
119 secure-ls:
120 specifier: ^2.0.0
121 version: 2.0.0
@@ -129,35 +129,35 @@ importers:
129 specifier: ^13.15.35
130 version: 13.15.35
131 vue:
132 - specifier: ^3.5.32
133 - version: 3.5.32(typescript@5.9.3)
132 + specifier: ^3.5.33
133 + version: 3.5.33(typescript@5.9.3)
134 vue-advanced-cropper:
135 specifier: ^2.8.9
136 - version: 2.8.9(vue@3.5.32(typescript@5.9.3))
136 + version: 2.8.9(vue@3.5.33(typescript@5.9.3))
137 vue-codemirror:
138 specifier: ^6.1.1
139 - version: 6.1.1(codemirror@6.0.2)(vue@3.5.32(typescript@5.9.3))
139 + version: 6.1.1(codemirror@6.0.2)(vue@3.5.33(typescript@5.9.3))
140 vue-highlight-words:
141 specifier: ^3.0.1
142 - version: 3.0.1(vue@3.5.32(typescript@5.9.3))
142 + version: 3.0.1(vue@3.5.33(typescript@5.9.3))
143 vue-i18n:
144 - specifier: ^11.3.2
145 - version: 11.3.2(vue@3.5.32(typescript@5.9.3))
144 + specifier: ^11.4.0
145 + version: 11.4.0(vue@3.5.33(typescript@5.9.3))
146 vue-router:
147 - specifier: ^5.0.4
148 - version: 5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
147 + specifier: ^5.0.6
148 + version: 5.0.6(@vue/compiler-sfc@3.5.33)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3)))(vue@3.5.33(typescript@5.9.3))
149 vue-sjv:
150 specifier: ^0.0.6
151 - version: 0.0.6(vue@3.5.32(typescript@5.9.3))
151 + version: 0.0.6(vue@3.5.33(typescript@5.9.3))
152 vue3-apexcharts:
153 specifier: ^1.11.1
154 - version: 1.11.1(apexcharts@5.10.6)(vue@3.5.32(typescript@5.9.3))
154 + version: 1.11.1(apexcharts@5.10.6)(vue@3.5.33(typescript@5.9.3))
155 vue3-marquee:
156 specifier: ^4.2.2
157 - version: 4.2.2(vue@3.5.32(typescript@5.9.3))
157 + version: 4.2.2(vue@3.5.33(typescript@5.9.3))
158 vuedraggable:
159 specifier: ^4.1.0
160 - version: 4.1.0(vue@3.5.32(typescript@5.9.3))
160 + version: 4.1.0(vue@3.5.33(typescript@5.9.3))
161 xmllint:
162 specifier: ^0.1.1
163 version: 0.1.1
@@ -167,16 +167,16 @@ importers:
167 devDependencies:
168 '@antfu/eslint-config':
169 specifier: ~8.2.0
170 - version: 8.2.0(@typescript-eslint/rule-tester@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3))(@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.32)(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.4(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)))
170 + version: 8.2.0(@typescript-eslint/rule-tester@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3))(@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.33)(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)))
171 '@clack/prompts':
172 specifier: ^1.2.0
173 version: 1.2.0
174 '@iconify/vue':
175 specifier: ^5.0.0
176 - version: 5.0.0(vue@3.5.32(typescript@5.9.3))
176 + version: 5.0.0(vue@3.5.33(typescript@5.9.3))
177 '@tailwindcss/vite':
178 - specifier: ^4.2.2
179 - version: 4.2.2(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))
178 + specifier: ^4.2.4
179 + version: 4.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))
180 '@tsconfig/node20':
181 specifier: ^20.1.9
182 version: 20.1.9
@@ -206,19 +206,19 @@ importers:
206 version: 13.15.10
207 '@vitejs/plugin-vue':
208 specifier: ^6.0.6
209 - version: 6.0.6(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
209 + version: 6.0.6(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.33(typescript@5.9.3))
210 '@vitejs/plugin-vue-jsx':
211 specifier: ^5.1.5
212 - version: 5.1.5(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
212 + version: 5.1.5(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.33(typescript@5.9.3))
213 '@vue/test-utils':
214 - specifier: ^2.4.6
215 - version: 2.4.6
214 + specifier: ^2.4.9
215 + version: 2.4.9(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@5.9.3)))(vue@3.5.33(typescript@5.9.3))
216 '@vue/tsconfig':
217 specifier: ~0.9.1
218 - version: 0.9.1(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
218 + version: 0.9.1(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3))
219 baseline-browser-mapping:
220 - specifier: ^2.10.20
221 - version: 2.10.20
220 + specifier: ^2.10.23
221 + version: 2.10.23
222 depcheck:
223 specifier: ^1.4.7
224 version: 1.4.7
@@ -244,8 +244,8 @@ importers:
244 specifier: ^3.8.3
245 version: 3.8.3
246 prettier-plugin-tailwindcss:
247 - specifier: ^0.7.2
248 - version: 0.7.2(prettier@3.8.3)
247 + specifier: ^0.7.3
248 + version: 0.7.3(prettier@3.8.3)
249 sass:
250 specifier: ^1.99.0
251 version: 1.99.0
@@ -253,8 +253,8 @@ importers:
253 specifier: ^3.0.2
254 version: 3.0.2
255 tailwindcss:
256 - specifier: ^4.2.2
257 - version: 4.2.2
256 + specifier: ^4.2.4
257 + version: 4.2.4
258 taze:
259 specifier: ^19.11.0
260 version: 19.11.0
@@ -275,13 +275,13 @@ importers:
275 version: 11.3.3(@nuxt/kit@3.21.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))
276 vite-plugin-vue-devtools:
277 specifier: ^8.1.1
278 - version: 8.1.1(@nuxt/kit@3.21.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
278 + version: 8.1.1(@nuxt/kit@3.21.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.33(typescript@5.9.3))
279 vite-svg-loader:
280 specifier: ^5.1.1
281 - version: 5.1.1(vue@3.5.32(typescript@5.9.3))
281 + version: 5.1.1(vue@3.5.33(typescript@5.9.3))
282 vitest:
283 - specifier: ^4.1.4
284 - version: 4.1.4(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))
283 + specifier: ^4.1.5
284 + version: 4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))
285 vue-tsc:
286 specifier: ^3.2.7
287 version: 3.2.7(typescript@5.9.3)
@@ -294,7 +294,7 @@ importers:
294 version: 0.3.11
295 vueuc:
296 specifier: ^0.4.64
297 - version: 0.4.65(vue@3.5.32(typescript@5.9.3))
297 + version: 0.4.65(vue@3.5.33(typescript@5.9.3))
298
299 packages:
300
@@ -980,20 +980,20 @@ packages:
980 peerDependencies:
981 vue: '>=3'
982
983 - '@intlify/core-base@11.3.2':
984 - resolution: {integrity: sha512-cgsUaV/dyD6aS49UPgerIblrWeXAZHNaDWqm4LujOGC7IafSyhghGXEiSVvuDYaDPiQTP+tSFSTM1HIu7Yp1nA==}
983 + '@intlify/core-base@11.4.0':
984 + resolution: {integrity: sha512-nlxFOnmjJgVkL1PsuSMagyh3qIHTwc2KlO2R3qQQV1ydrcwh1XpM7opWUGqvGaLlktttopDzbLBpr/k5tvbNmA==}
985 engines: {node: '>= 16'}
986
987 - '@intlify/devtools-types@11.3.2':
988 - resolution: {integrity: sha512-q96G2ZZw0FNoXzejbjIf9dbfgz1xyYBZu6ZT4b5TE/55j8d1O9X5jv0k+U+L3fVe7uebPcqRQFD0ffm30i5mJA==}
987 + '@intlify/devtools-types@11.4.0':
988 + resolution: {integrity: sha512-LtQ04kG8/2Nv6AbuINpkjODuhKHdd+MGLlXKW3I0GTCeDsDIBZUot82nnyK7D6+qersF08FqSvoN/eGPcL3c7Q==}
989 engines: {node: '>= 16'}
990
991 - '@intlify/message-compiler@11.3.2':
992 - resolution: {integrity: sha512-d/awyHUkNSaGPxBxT/qlUpfRizxHX9dt55CnW03xx5p1KmMyfYHKupCnvzINX+Na8JR8LAR7y32lPKjoeQGmzA==}
991 + '@intlify/message-compiler@11.4.0':
992 + resolution: {integrity: sha512-v455gVZqMb0er63Wd/akX8DXTnwSubgrgQaRigLB60V3xpnq3B99oPvGXW+N4G/5QFt8Ls84FJ8qHJUVnRCs1A==}
993 engines: {node: '>= 16'}
994
995 - '@intlify/shared@11.3.2':
996 - resolution: {integrity: sha512-x66fjdH6i+lNYPae5URSQGTjBL68Av6hi09jvC5Ci96iTkwfqrPhCj46aylQZmgMaG89rOZCIKqS7ApC8ZDVjg==}
995 + '@intlify/shared@11.4.0':
996 + resolution: {integrity: sha512-r9qUeLeO0TMZmUZ+mXS6IGQ6xwzZJaVMK6j4CdoA3eQP8xp3JtCfwkZ30gB4+knlN40pmBdDXgx85SWhMCzHng==}
997 engines: {node: '>= 16'}
998
999 '@isaacs/cliui@8.0.2':
@@ -1383,69 +1383,69 @@ packages:
1383 peerDependencies:
1384 eslint: ^9.0.0 || ^10.0.0
1385
1386 - '@tailwindcss/node@4.2.2':
1387 - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
1386 + '@tailwindcss/node@4.2.4':
1387 + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
1388
1389 - '@tailwindcss/oxide-android-arm64@4.2.2':
1390 - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==}
1389 + '@tailwindcss/oxide-android-arm64@4.2.4':
1390 + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==}
1391 engines: {node: '>= 20'}
1392 cpu: [arm64]
1393 os: [android]
1394
1395 - '@tailwindcss/oxide-darwin-arm64@4.2.2':
1396 - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==}
1395 + '@tailwindcss/oxide-darwin-arm64@4.2.4':
1396 + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==}
1397 engines: {node: '>= 20'}
1398 cpu: [arm64]
1399 os: [darwin]
1400
1401 - '@tailwindcss/oxide-darwin-x64@4.2.2':
1402 - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==}
1401 + '@tailwindcss/oxide-darwin-x64@4.2.4':
1402 + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==}
1403 engines: {node: '>= 20'}
1404 cpu: [x64]
1405 os: [darwin]
1406
1407 - '@tailwindcss/oxide-freebsd-x64@4.2.2':
1408 - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==}
1407 + '@tailwindcss/oxide-freebsd-x64@4.2.4':
1408 + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==}
1409 engines: {node: '>= 20'}
1410 cpu: [x64]
1411 os: [freebsd]
1412
1413 - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
1414 - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==}
1413 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
1414 + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==}
1415 engines: {node: '>= 20'}
1416 cpu: [arm]
1417 os: [linux]
1418
1419 - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
1420 - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==}
1419 + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
1420 + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==}
1421 engines: {node: '>= 20'}
1422 cpu: [arm64]
1423 os: [linux]
1424 libc: [glibc]
1425
1426 - '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
1427 - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
1426 + '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
1427 + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
1428 engines: {node: '>= 20'}
1429 cpu: [arm64]
1430 os: [linux]
1431 libc: [musl]
1432
1433 - '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
1434 - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
1433 + '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
1434 + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
1435 engines: {node: '>= 20'}
1436 cpu: [x64]
1437 os: [linux]
1438 libc: [glibc]
1439
1440 - '@tailwindcss/oxide-linux-x64-musl@4.2.2':
1441 - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
1440 + '@tailwindcss/oxide-linux-x64-musl@4.2.4':
1441 + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
1442 engines: {node: '>= 20'}
1443 cpu: [x64]
1444 os: [linux]
1445 libc: [musl]
1446
1447 - '@tailwindcss/oxide-wasm32-wasi@4.2.2':
1448 - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
1447 + '@tailwindcss/oxide-wasm32-wasi@4.2.4':
1448 + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
1449 engines: {node: '>=14.0.0'}
1450 cpu: [wasm32]
1451 bundledDependencies:
@@ -1456,24 +1456,24 @@ packages:
1456 - '@emnapi/wasi-threads'
1457 - tslib
1458
1459 - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
1460 - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==}
1459 + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
1460 + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==}
1461 engines: {node: '>= 20'}
1462 cpu: [arm64]
1463 os: [win32]
1464
1465 - '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
1466 - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==}
1465 + '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
1466 + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==}
1467 engines: {node: '>= 20'}
1468 cpu: [x64]
1469 os: [win32]
1470
1471 - '@tailwindcss/oxide@4.2.2':
1472 - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==}
1471 + '@tailwindcss/oxide@4.2.4':
1472 + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==}
1473 engines: {node: '>= 20'}
1474
1475 - '@tailwindcss/vite@4.2.2':
1476 - resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==}
1475 + '@tailwindcss/vite@4.2.4':
1476 + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==}
1477 peerDependencies:
1478 vite: ^5.2.0 || ^6 || ^7 || ^8
1479
@@ -1668,11 +1668,11 @@ packages:
1668 vitest:
1669 optional: true
1670
1671 - '@vitest/expect@4.1.4':
1672 - resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==}
1671 + '@vitest/expect@4.1.5':
1672 + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
1673
1674 - '@vitest/mocker@4.1.4':
1675 - resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==}
1674 + '@vitest/mocker@4.1.5':
1675 + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==}
1676 peerDependencies:
1677 msw: ^2.4.9
1678 vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -1682,20 +1682,20 @@ packages:
1682 vite:
1683 optional: true
1684
1685 - '@vitest/pretty-format@4.1.4':
1686 - resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==}
1685 + '@vitest/pretty-format@4.1.5':
1686 + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==}
1687
1688 - '@vitest/runner@4.1.4':
1689 - resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==}
1688 + '@vitest/runner@4.1.5':
1689 + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==}
1690
1691 - '@vitest/snapshot@4.1.4':
1692 - resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==}
1691 + '@vitest/snapshot@4.1.5':
1692 + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==}
1693
1694 - '@vitest/spy@4.1.4':
1695 - resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==}
1694 + '@vitest/spy@4.1.5':
1695 + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==}
1696
1697 - '@vitest/utils@4.1.4':
1698 - resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==}
1697 + '@vitest/utils@4.1.5':
1698 + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
1699
1700 '@volar/language-core@2.4.28':
1701 resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
@@ -1750,15 +1750,27 @@ packages:
1750 '@vue/compiler-core@3.5.32':
1751 resolution: {integrity: sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==}
1752
1753 + '@vue/compiler-core@3.5.33':
1754 + resolution: {integrity: sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==}
1755 +
1756 '@vue/compiler-dom@3.5.32':
1757 resolution: {integrity: sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==}
1758
1759 + '@vue/compiler-dom@3.5.33':
1760 + resolution: {integrity: sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==}
1761 +
1762 '@vue/compiler-sfc@3.5.32':
1763 resolution: {integrity: sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==}
1764
1765 + '@vue/compiler-sfc@3.5.33':
1766 + resolution: {integrity: sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==}
1767 +
1768 '@vue/compiler-ssr@3.5.32':
1769 resolution: {integrity: sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==}
1770
1771 + '@vue/compiler-ssr@3.5.33':
1772 + resolution: {integrity: sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==}
1773 +
1774 '@vue/devtools-api@6.6.4':
1775 resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
1776
@@ -1788,25 +1800,35 @@ packages:
1800 '@vue/language-core@3.2.7':
1801 resolution: {integrity: sha512-Gn4q/tRxbpVGLEuARQ43p3YELlNAFgRUVCgW9U5Cr+5q4vfD2bWDWpl3ABbJMXUt5xlE1dF8dkigg2aUq7JYYw==}
1802
1791 - '@vue/reactivity@3.5.32':
1792 - resolution: {integrity: sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==}
1803 + '@vue/reactivity@3.5.33':
1804 + resolution: {integrity: sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==}
1805
1794 - '@vue/runtime-core@3.5.32':
1795 - resolution: {integrity: sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==}
1806 + '@vue/runtime-core@3.5.33':
1807 + resolution: {integrity: sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==}
1808
1797 - '@vue/runtime-dom@3.5.32':
1798 - resolution: {integrity: sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==}
1809 + '@vue/runtime-dom@3.5.33':
1810 + resolution: {integrity: sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==}
1811
1800 - '@vue/server-renderer@3.5.32':
1801 - resolution: {integrity: sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==}
1812 + '@vue/server-renderer@3.5.33':
1813 + resolution: {integrity: sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==}
1814 peerDependencies:
1803 - vue: 3.5.32
1815 + vue: 3.5.33
1816
1817 '@vue/shared@3.5.32':
1818 resolution: {integrity: sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==}
1819
1808 - '@vue/test-utils@2.4.6':
1809 - resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==}
1820 + '@vue/shared@3.5.33':
1821 + resolution: {integrity: sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==}
1822 +
1823 + '@vue/test-utils@2.4.9':
1824 + resolution: {integrity: sha512-YwgowiO1mPleZqpgAGfxvWu/A5A8nkLrbyH2SqiQRkyzCIaDzzo27/2uS/F1g7fRLvl8BUY0+Sr1eC+6+IHfrw==}
1825 + peerDependencies:
1826 + '@vue/compiler-dom': 3.x
1827 + '@vue/server-renderer': 3.x
1828 + vue: 3.x
1829 + peerDependenciesMeta:
1830 + '@vue/server-renderer':
1831 + optional: true
1832
1833 '@vue/tsconfig@0.9.1':
1834 resolution: {integrity: sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==}
@@ -1951,8 +1973,8 @@ packages:
1973 asynckit@0.4.0:
1974 resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
1975
1954 - axios@1.15.1:
1955 - resolution: {integrity: sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==}
1976 + axios@1.15.2:
1977 + resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==}
1978
1979 babel-walk@3.0.0-canary-5:
1980 resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==}
@@ -1968,8 +1990,8 @@ packages:
1990 resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
1991 engines: {node: 18 || 20 || >=22}
1992
1971 - baseline-browser-mapping@2.10.20:
1972 - resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==}
1993 + baseline-browser-mapping@2.10.23:
1994 + resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==}
1995 engines: {node: '>=6.0.0'}
1996 hasBin: true
1997
@@ -2713,8 +2735,8 @@ packages:
2735 fast-xml-builder@1.1.5:
2736 resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==}
2737
2716 - fast-xml-parser@5.7.1:
2717 - resolution: {integrity: sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==}
2738 + fast-xml-parser@5.7.2:
2739 + resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
2740 hasBin: true
2741
2742 fastq@1.20.1:
@@ -3778,8 +3800,8 @@ packages:
3800 resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
3801 engines: {node: '>= 0.8.0'}
3802
3781 - prettier-plugin-tailwindcss@0.7.2:
3782 - resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==}
3803 + prettier-plugin-tailwindcss@0.7.3:
3804 + resolution: {integrity: sha512-lckXaWWdo2ZVXoMoUO3WIBiz9hVY+YBEh1gYyMFfrWP9WZW/wpFXQKizHx7WrFQFMkcG0bGShdpp531X1n+qpg==}
3805 engines: {node: '>=20.19'}
3806 peerDependencies:
3807 '@ianvs/prettier-plugin-sort-imports': '*'
@@ -4208,8 +4230,8 @@ packages:
4230 resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
4231 engines: {node: '>=20'}
4232
4211 - tailwindcss@4.2.2:
4212 - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
4233 + tailwindcss@4.2.4:
4234 + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==}
4235
4236 tapable@2.3.2:
4237 resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
@@ -4493,20 +4515,20 @@ packages:
4515 yaml:
4516 optional: true
4517
4496 - vitest@4.1.4:
4497 - resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==}
4518 + vitest@4.1.5:
4519 + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==}
4520 engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
4521 hasBin: true
4522 peerDependencies:
4523 '@edge-runtime/vm': '*'
4524 '@opentelemetry/api': ^1.9.0
4525 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
4504 - '@vitest/browser-playwright': 4.1.4
4505 - '@vitest/browser-preview': 4.1.4
4506 - '@vitest/browser-webdriverio': 4.1.4
4507 - '@vitest/coverage-istanbul': 4.1.4
4508 - '@vitest/coverage-v8': 4.1.4
4509 - '@vitest/ui': 4.1.4
4526 + '@vitest/browser-playwright': 4.1.5
4527 + '@vitest/browser-preview': 4.1.5
4528 + '@vitest/browser-webdriverio': 4.1.5
4529 + '@vitest/coverage-istanbul': 4.1.5
4530 + '@vitest/coverage-v8': 4.1.5
4531 + '@vitest/ui': 4.1.5
4532 happy-dom: '*'
4533 jsdom: '*'
4534 vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -4558,8 +4580,8 @@ packages:
4580 codemirror: 6.x
4581 vue: 3.x
4582
4561 - vue-component-type-helpers@2.2.12:
4562 - resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
4583 + vue-component-type-helpers@3.2.7:
4584 + resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
4585
4586 vue-eslint-parser@10.4.0:
4587 resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==}
@@ -4572,14 +4594,14 @@ packages:
4594 peerDependencies:
4595 vue: ^3.0.0
4596
4575 - vue-i18n@11.3.2:
4576 - resolution: {integrity: sha512-gmFrvM+iuf2AH4ygligw/pC7PRJ63AdRNE68E0GPlQ83Mzfyck6g6cRQC3KzkYXr+ZidR91wq+5YBmAMpkgE1A==}
4597 + vue-i18n@11.4.0:
4598 + resolution: {integrity: sha512-gxLVtcwdvOgwKSzkdb7nHKlW0N85A6aDNmHLnq6V+3w2/BXy/os5l71P7TIlgIQTxX0zJjiz89iImoHi51GieQ==}
4599 engines: {node: '>= 16'}
4600 peerDependencies:
4601 vue: ^3.0.0
4602
4581 - vue-router@5.0.4:
4582 - resolution: {integrity: sha512-lCqDLCI2+fKVRl2OzXuzdSWmxXFLQRxQbmHugnRpTMyYiT+hNaycV0faqG5FBHDXoYrZ6MQcX87BvbY8mQ20Bg==}
4603 + vue-router@5.0.6:
4604 + resolution: {integrity: sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==}
4605 peerDependencies:
4606 '@pinia/colada': '>=0.21.2'
4607 '@vue/compiler-sfc': ^3.5.17
@@ -4616,8 +4638,8 @@ packages:
4638 peerDependencies:
4639 vue: ^3.2
4640
4619 - vue@3.5.32:
4620 - resolution: {integrity: sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==}
4641 + vue@3.5.33:
4642 + resolution: {integrity: sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==}
4643 peerDependencies:
4644 typescript: '*'
4645 peerDependenciesMeta:
@@ -4859,7 +4881,7 @@ snapshots:
4881 dependencies:
4882 '@algolia/client-common': 5.50.2
4883
4862 - '@antfu/eslint-config@8.2.0(@typescript-eslint/rule-tester@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3))(@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.32)(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.4(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)))':
4884 + '@antfu/eslint-config@8.2.0(@typescript-eslint/rule-tester@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3))(@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.33)(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)))':
4885 dependencies:
4886 '@antfu/install-pkg': 1.1.0
4887 '@clack/prompts': 1.2.0
@@ -4869,7 +4891,7 @@ snapshots:
4891 '@stylistic/eslint-plugin': 5.10.0(eslint@10.2.1(jiti@2.6.1))
4892 '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
4893 '@typescript-eslint/parser': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
4872 - '@vitest/eslint-plugin': 1.6.16(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.4(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)))
4894 + '@vitest/eslint-plugin': 1.6.16(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)))
4895 ansis: 4.2.0
4896 cac: 7.0.0
4897 eslint: 10.2.1(jiti@2.6.1)
@@ -4891,7 +4913,7 @@ snapshots:
4913 eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))
4914 eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.2.1(jiti@2.6.1)))(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.2.1(jiti@2.6.1)))
4915 eslint-plugin-yml: 3.3.1(eslint@10.2.1(jiti@2.6.1))
4894 - eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.32)(eslint@10.2.1(jiti@2.6.1))
4916 + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.33)(eslint@10.2.1(jiti@2.6.1))
4917 globals: 17.5.0
4918 local-pkg: 1.1.2
4919 parse-gitignore: 2.0.0
@@ -5227,9 +5249,9 @@ snapshots:
5249 dependencies:
5250 css-render: 0.15.14
5251
5230 - '@css-render/vue3-ssr@0.15.14(vue@3.5.32(typescript@5.9.3))':
5252 + '@css-render/vue3-ssr@0.15.14(vue@3.5.33(typescript@5.9.3))':
5253 dependencies:
5232 - vue: 3.5.32(typescript@5.9.3)
5254 + vue: 3.5.33(typescript@5.9.3)
5255
5256 '@csstools/color-helpers@6.0.2': {}
5257
@@ -5424,10 +5446,10 @@ snapshots:
5446
5447 '@exodus/bytes@1.15.0': {}
5448
5427 - '@f3ve/vue-markdown-it@0.2.3(vue@3.5.32(typescript@5.9.3))':
5449 + '@f3ve/vue-markdown-it@0.2.3(vue@3.5.33(typescript@5.9.3))':
5450 dependencies:
5451 markdown-it: 14.1.1
5430 - vue: 3.5.32(typescript@5.9.3)
5452 + vue: 3.5.33(typescript@5.9.3)
5453
5454 '@fontsource/jetbrains-mono@5.2.8': {}
5455
@@ -5471,28 +5493,28 @@ snapshots:
5493
5494 '@iconify/types@2.0.0': {}
5495
5474 - '@iconify/vue@5.0.0(vue@3.5.32(typescript@5.9.3))':
5496 + '@iconify/vue@5.0.0(vue@3.5.33(typescript@5.9.3))':
5497 dependencies:
5498 '@iconify/types': 2.0.0
5477 - vue: 3.5.32(typescript@5.9.3)
5499 + vue: 3.5.33(typescript@5.9.3)
5500
5479 - '@intlify/core-base@11.3.2':
5501 + '@intlify/core-base@11.4.0':
5502 dependencies:
5481 - '@intlify/devtools-types': 11.3.2
5482 - '@intlify/message-compiler': 11.3.2
5483 - '@intlify/shared': 11.3.2
5503 + '@intlify/devtools-types': 11.4.0
5504 + '@intlify/message-compiler': 11.4.0
5505 + '@intlify/shared': 11.4.0
5506
5485 - '@intlify/devtools-types@11.3.2':
5507 + '@intlify/devtools-types@11.4.0':
5508 dependencies:
5487 - '@intlify/core-base': 11.3.2
5488 - '@intlify/shared': 11.3.2
5509 + '@intlify/core-base': 11.4.0
5510 + '@intlify/shared': 11.4.0
5511
5490 - '@intlify/message-compiler@11.3.2':
5512 + '@intlify/message-compiler@11.4.0':
5513 dependencies:
5492 - '@intlify/shared': 11.3.2
5514 + '@intlify/shared': 11.4.0
5515 source-map-js: 1.2.1
5516
5495 - '@intlify/shared@11.3.2': {}
5517 + '@intlify/shared@11.4.0': {}
5518
5519 '@isaacs/cliui@8.0.2':
5520 dependencies:
@@ -5844,7 +5866,7 @@ snapshots:
5866 estraverse: 5.3.0
5867 picomatch: 4.0.4
5868
5847 - '@tailwindcss/node@4.2.2':
5869 + '@tailwindcss/node@4.2.4':
5870 dependencies:
5871 '@jridgewell/remapping': 2.3.5
5872 enhanced-resolve: 5.20.1
@@ -5852,64 +5874,64 @@ snapshots:
5874 lightningcss: 1.32.0
5875 magic-string: 0.30.21
5876 source-map-js: 1.2.1
5855 - tailwindcss: 4.2.2
5877 + tailwindcss: 4.2.4
5878
5857 - '@tailwindcss/oxide-android-arm64@4.2.2':
5879 + '@tailwindcss/oxide-android-arm64@4.2.4':
5880 optional: true
5881
5860 - '@tailwindcss/oxide-darwin-arm64@4.2.2':
5882 + '@tailwindcss/oxide-darwin-arm64@4.2.4':
5883 optional: true
5884
5863 - '@tailwindcss/oxide-darwin-x64@4.2.2':
5885 + '@tailwindcss/oxide-darwin-x64@4.2.4':
5886 optional: true
5887
5866 - '@tailwindcss/oxide-freebsd-x64@4.2.2':
5888 + '@tailwindcss/oxide-freebsd-x64@4.2.4':
5889 optional: true
5890
5869 - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
5891 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
5892 optional: true
5893
5872 - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
5894 + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
5895 optional: true
5896
5875 - '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
5897 + '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
5898 optional: true
5899
5878 - '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
5900 + '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
5901 optional: true
5902
5881 - '@tailwindcss/oxide-linux-x64-musl@4.2.2':
5903 + '@tailwindcss/oxide-linux-x64-musl@4.2.4':
5904 optional: true
5905
5884 - '@tailwindcss/oxide-wasm32-wasi@4.2.2':
5906 + '@tailwindcss/oxide-wasm32-wasi@4.2.4':
5907 optional: true
5908
5887 - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
5909 + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
5910 optional: true
5911
5890 - '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
5912 + '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
5913 optional: true
5914
5893 - '@tailwindcss/oxide@4.2.2':
5915 + '@tailwindcss/oxide@4.2.4':
5916 optionalDependencies:
5895 - '@tailwindcss/oxide-android-arm64': 4.2.2
5896 - '@tailwindcss/oxide-darwin-arm64': 4.2.2
5897 - '@tailwindcss/oxide-darwin-x64': 4.2.2
5898 - '@tailwindcss/oxide-freebsd-x64': 4.2.2
5899 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2
5900 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2
5901 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2
5902 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2
5903 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2
5904 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2
5905 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
5906 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2
5907 -
5908 - '@tailwindcss/vite@4.2.2(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))':
5909 - dependencies:
5910 - '@tailwindcss/node': 4.2.2
5911 - '@tailwindcss/oxide': 4.2.2
5912 - tailwindcss: 4.2.2
5917 + '@tailwindcss/oxide-android-arm64': 4.2.4
5918 + '@tailwindcss/oxide-darwin-arm64': 4.2.4
5919 + '@tailwindcss/oxide-darwin-x64': 4.2.4
5920 + '@tailwindcss/oxide-freebsd-x64': 4.2.4
5921 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4
5922 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4
5923 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4
5924 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4
5925 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4
5926 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4
5927 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
5928 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4
5929 +
5930 + '@tailwindcss/vite@4.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))':
5931 + dependencies:
5932 + '@tailwindcss/node': 4.2.4
5933 + '@tailwindcss/oxide': 4.2.4
5934 + tailwindcss: 4.2.4
5935 vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)
5936
5937 '@tsconfig/node20@20.1.9': {}
@@ -6111,7 +6133,7 @@ snapshots:
6133
6134 '@ungap/structured-clone@1.3.0': {}
6135
6114 - '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
6136 + '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.33(typescript@5.9.3))':
6137 dependencies:
6138 '@babel/core': 7.29.0
6139 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
@@ -6119,17 +6141,17 @@ snapshots:
6141 '@rolldown/pluginutils': 1.0.0-rc.16
6142 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0)
6143 vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)
6122 - vue: 3.5.32(typescript@5.9.3)
6144 + vue: 3.5.33(typescript@5.9.3)
6145 transitivePeerDependencies:
6146 - supports-color
6147
6126 - '@vitejs/plugin-vue@6.0.6(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
6148 + '@vitejs/plugin-vue@6.0.6(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.33(typescript@5.9.3))':
6149 dependencies:
6150 '@rolldown/pluginutils': 1.0.0-rc.13
6151 vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)
6130 - vue: 3.5.32(typescript@5.9.3)
6152 + vue: 3.5.33(typescript@5.9.3)
6153
6132 - '@vitest/eslint-plugin@1.6.16(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.4(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)))':
6154 + '@vitest/eslint-plugin@1.6.16(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)))':
6155 dependencies:
6156 '@typescript-eslint/scope-manager': 8.58.2
6157 '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
@@ -6137,48 +6159,48 @@ snapshots:
6159 optionalDependencies:
6160 '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
6161 typescript: 5.9.3
6140 - vitest: 4.1.4(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))
6162 + vitest: 4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))
6163 transitivePeerDependencies:
6164 - supports-color
6165
6144 - '@vitest/expect@4.1.4':
6166 + '@vitest/expect@4.1.5':
6167 dependencies:
6168 '@standard-schema/spec': 1.1.0
6169 '@types/chai': 5.2.3
6148 - '@vitest/spy': 4.1.4
6149 - '@vitest/utils': 4.1.4
6170 + '@vitest/spy': 4.1.5
6171 + '@vitest/utils': 4.1.5
6172 chai: 6.2.2
6173 tinyrainbow: 3.1.0
6174
6153 - '@vitest/mocker@4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))':
6175 + '@vitest/mocker@4.1.5(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))':
6176 dependencies:
6155 - '@vitest/spy': 4.1.4
6177 + '@vitest/spy': 4.1.5
6178 estree-walker: 3.0.3
6179 magic-string: 0.30.21
6180 optionalDependencies:
6181 vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)
6182
6161 - '@vitest/pretty-format@4.1.4':
6183 + '@vitest/pretty-format@4.1.5':
6184 dependencies:
6185 tinyrainbow: 3.1.0
6186
6165 - '@vitest/runner@4.1.4':
6187 + '@vitest/runner@4.1.5':
6188 dependencies:
6167 - '@vitest/utils': 4.1.4
6189 + '@vitest/utils': 4.1.5
6190 pathe: 2.0.3
6191
6170 - '@vitest/snapshot@4.1.4':
6192 + '@vitest/snapshot@4.1.5':
6193 dependencies:
6172 - '@vitest/pretty-format': 4.1.4
6173 - '@vitest/utils': 4.1.4
6194 + '@vitest/pretty-format': 4.1.5
6195 + '@vitest/utils': 4.1.5
6196 magic-string: 0.30.21
6197 pathe: 2.0.3
6198
6177 - '@vitest/spy@4.1.4': {}
6199 + '@vitest/spy@4.1.5': {}
6200
6179 - '@vitest/utils@4.1.4':
6201 + '@vitest/utils@4.1.5':
6202 dependencies:
6181 - '@vitest/pretty-format': 4.1.4
6203 + '@vitest/pretty-format': 4.1.5
6204 convert-source-map: 2.0.0
6205 tinyrainbow: 3.1.0
6206
@@ -6194,7 +6216,7 @@ snapshots:
6216 path-browserify: 1.0.1
6217 vscode-uri: 3.1.0
6218
6197 - '@vue-macros/common@3.1.2(vue@3.5.32(typescript@5.9.3))':
6219 + '@vue-macros/common@3.1.2(vue@3.5.33(typescript@5.9.3))':
6220 dependencies:
6221 '@vue/compiler-sfc': 3.5.32
6222 ast-kit: 2.2.0
@@ -6202,7 +6224,7 @@ snapshots:
6224 magic-string-ast: 1.0.3
6225 unplugin-utils: 0.3.1
6226 optionalDependencies:
6205 - vue: 3.5.32(typescript@5.9.3)
6227 + vue: 3.5.33(typescript@5.9.3)
6228
6229 '@vue/babel-helper-vue-transform-on@1.5.0': {}
6230
@@ -6270,11 +6292,24 @@ snapshots:
6292 estree-walker: 2.0.2
6293 source-map-js: 1.2.1
6294
6295 + '@vue/compiler-core@3.5.33':
6296 + dependencies:
6297 + '@babel/parser': 7.29.2
6298 + '@vue/shared': 3.5.33
6299 + entities: 7.0.1
6300 + estree-walker: 2.0.2
6301 + source-map-js: 1.2.1
6302 +
6303 '@vue/compiler-dom@3.5.32':
6304 dependencies:
6305 '@vue/compiler-core': 3.5.32
6306 '@vue/shared': 3.5.32
6307
6308 + '@vue/compiler-dom@3.5.33':
6309 + dependencies:
6310 + '@vue/compiler-core': 3.5.33
6311 + '@vue/shared': 3.5.33
6312 +
6313 '@vue/compiler-sfc@3.5.32':
6314 dependencies:
6315 '@babel/parser': 7.29.2
@@ -6287,11 +6322,28 @@ snapshots:
6322 postcss: 8.5.10
6323 source-map-js: 1.2.1
6324
6325 + '@vue/compiler-sfc@3.5.33':
6326 + dependencies:
6327 + '@babel/parser': 7.29.2
6328 + '@vue/compiler-core': 3.5.33
6329 + '@vue/compiler-dom': 3.5.33
6330 + '@vue/compiler-ssr': 3.5.33
6331 + '@vue/shared': 3.5.33
6332 + estree-walker: 2.0.2
6333 + magic-string: 0.30.21
6334 + postcss: 8.5.10
6335 + source-map-js: 1.2.1
6336 +
6337 '@vue/compiler-ssr@3.5.32':
6338 dependencies:
6339 '@vue/compiler-dom': 3.5.32
6340 '@vue/shared': 3.5.32
6341
6342 + '@vue/compiler-ssr@3.5.33':
6343 + dependencies:
6344 + '@vue/compiler-dom': 3.5.33
6345 + '@vue/shared': 3.5.33
6346 +
6347 '@vue/devtools-api@6.6.4': {}
6348
6349 '@vue/devtools-api@7.7.9':
@@ -6302,11 +6354,11 @@ snapshots:
6354 dependencies:
6355 '@vue/devtools-kit': 8.1.1
6356
6305 - '@vue/devtools-core@8.1.1(vue@3.5.32(typescript@5.9.3))':
6357 + '@vue/devtools-core@8.1.1(vue@3.5.33(typescript@5.9.3))':
6358 dependencies:
6359 '@vue/devtools-kit': 8.1.1
6360 '@vue/devtools-shared': 8.1.1
6309 - vue: 3.5.32(typescript@5.9.3)
6361 + vue: 3.5.33(typescript@5.9.3)
6362
6363 '@vue/devtools-kit@7.7.9':
6364 dependencies:
@@ -6341,79 +6393,85 @@ snapshots:
6393 path-browserify: 1.0.1
6394 picomatch: 4.0.4
6395
6344 - '@vue/reactivity@3.5.32':
6396 + '@vue/reactivity@3.5.33':
6397 dependencies:
6346 - '@vue/shared': 3.5.32
6398 + '@vue/shared': 3.5.33
6399
6348 - '@vue/runtime-core@3.5.32':
6400 + '@vue/runtime-core@3.5.33':
6401 dependencies:
6350 - '@vue/reactivity': 3.5.32
6351 - '@vue/shared': 3.5.32
6402 + '@vue/reactivity': 3.5.33
6403 + '@vue/shared': 3.5.33
6404
6353 - '@vue/runtime-dom@3.5.32':
6405 + '@vue/runtime-dom@3.5.33':
6406 dependencies:
6355 - '@vue/reactivity': 3.5.32
6356 - '@vue/runtime-core': 3.5.32
6357 - '@vue/shared': 3.5.32
6407 + '@vue/reactivity': 3.5.33
6408 + '@vue/runtime-core': 3.5.33
6409 + '@vue/shared': 3.5.33
6410 csstype: 3.2.3
6411
6360 - '@vue/server-renderer@3.5.32(vue@3.5.32(typescript@5.9.3))':
6412 + '@vue/server-renderer@3.5.33(vue@3.5.33(typescript@5.9.3))':
6413 dependencies:
6362 - '@vue/compiler-ssr': 3.5.32
6363 - '@vue/shared': 3.5.32
6364 - vue: 3.5.32(typescript@5.9.3)
6414 + '@vue/compiler-ssr': 3.5.33
6415 + '@vue/shared': 3.5.33
6416 + vue: 3.5.33(typescript@5.9.3)
6417
6418 '@vue/shared@3.5.32': {}
6419
6368 - '@vue/test-utils@2.4.6':
6420 + '@vue/shared@3.5.33': {}
6421 +
6422 + '@vue/test-utils@2.4.9(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@5.9.3)))(vue@3.5.33(typescript@5.9.3))':
6423 dependencies:
6424 + '@vue/compiler-dom': 3.5.33
6425 js-beautify: 1.15.4
6371 - vue-component-type-helpers: 2.2.12
6426 + vue: 3.5.33(typescript@5.9.3)
6427 + vue-component-type-helpers: 3.2.7
6428 + optionalDependencies:
6429 + '@vue/server-renderer': 3.5.33(vue@3.5.33(typescript@5.9.3))
6430
6373 - '@vue/tsconfig@0.9.1(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))':
6431 + '@vue/tsconfig@0.9.1(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3))':
6432 optionalDependencies:
6433 typescript: 5.9.3
6376 - vue: 3.5.32(typescript@5.9.3)
6434 + vue: 3.5.33(typescript@5.9.3)
6435
6378 - '@vueuse/core@13.9.0(vue@3.5.32(typescript@5.9.3))':
6436 + '@vueuse/core@13.9.0(vue@3.5.33(typescript@5.9.3))':
6437 dependencies:
6438 '@types/web-bluetooth': 0.0.21
6439 '@vueuse/metadata': 13.9.0
6382 - '@vueuse/shared': 13.9.0(vue@3.5.32(typescript@5.9.3))
6383 - vue: 3.5.32(typescript@5.9.3)
6440 + '@vueuse/shared': 13.9.0(vue@3.5.33(typescript@5.9.3))
6441 + vue: 3.5.33(typescript@5.9.3)
6442
6385 - '@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3))':
6443 + '@vueuse/core@14.2.1(vue@3.5.33(typescript@5.9.3))':
6444 dependencies:
6445 '@types/web-bluetooth': 0.0.21
6446 '@vueuse/metadata': 14.2.1
6389 - '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3))
6390 - vue: 3.5.32(typescript@5.9.3)
6447 + '@vueuse/shared': 14.2.1(vue@3.5.33(typescript@5.9.3))
6448 + vue: 3.5.33(typescript@5.9.3)
6449
6450 '@vueuse/metadata@13.9.0': {}
6451
6452 '@vueuse/metadata@14.2.1': {}
6453
6396 - '@vueuse/motion@3.0.3(vue@3.5.32(typescript@5.9.3))':
6454 + '@vueuse/motion@3.0.3(vue@3.5.33(typescript@5.9.3))':
6455 dependencies:
6398 - '@vueuse/core': 13.9.0(vue@3.5.32(typescript@5.9.3))
6399 - '@vueuse/shared': 13.9.0(vue@3.5.32(typescript@5.9.3))
6456 + '@vueuse/core': 13.9.0(vue@3.5.33(typescript@5.9.3))
6457 + '@vueuse/shared': 13.9.0(vue@3.5.33(typescript@5.9.3))
6458 defu: 6.1.7
6459 framesync: 6.1.2
6460 popmotion: 11.0.5
6461 style-value-types: 5.1.2
6404 - vue: 3.5.32(typescript@5.9.3)
6462 + vue: 3.5.33(typescript@5.9.3)
6463 optionalDependencies:
6464 '@nuxt/kit': 3.21.2
6465 transitivePeerDependencies:
6466 - magicast
6467
6410 - '@vueuse/shared@13.9.0(vue@3.5.32(typescript@5.9.3))':
6468 + '@vueuse/shared@13.9.0(vue@3.5.33(typescript@5.9.3))':
6469 dependencies:
6412 - vue: 3.5.32(typescript@5.9.3)
6470 + vue: 3.5.33(typescript@5.9.3)
6471
6414 - '@vueuse/shared@14.2.1(vue@3.5.32(typescript@5.9.3))':
6472 + '@vueuse/shared@14.2.1(vue@3.5.33(typescript@5.9.3))':
6473 dependencies:
6416 - vue: 3.5.32(typescript@5.9.3)
6474 + vue: 3.5.33(typescript@5.9.3)
6475
6476 abbrev@2.0.0: {}
6477
@@ -6501,7 +6559,7 @@ snapshots:
6559
6560 asynckit@0.4.0: {}
6561
6504 - axios@1.15.1(debug@4.4.3):
6562 + axios@1.15.2(debug@4.4.3):
6563 dependencies:
6564 follow-redirects: 1.16.0(debug@4.4.3)
6565 form-data: 4.0.5
@@ -6519,7 +6577,7 @@ snapshots:
6577
6578 balanced-match@4.0.4: {}
6579
6522 - baseline-browser-mapping@2.10.20: {}
6580 + baseline-browser-mapping@2.10.23: {}
6581
6582 bidi-js@1.0.3:
6583 dependencies:
@@ -6555,7 +6613,7 @@ snapshots:
6613
6614 browserslist@4.28.2:
6615 dependencies:
6558 - baseline-browser-mapping: 2.10.20
6616 + baseline-browser-mapping: 2.10.23
6617 caniuse-lite: 1.0.30001788
6618 electron-to-chromium: 1.5.340
6619 node-releases: 2.0.37
@@ -7213,9 +7271,9 @@ snapshots:
7271 transitivePeerDependencies:
7272 - supports-color
7273
7216 - eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.32)(eslint@10.2.1(jiti@2.6.1)):
7274 + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.33)(eslint@10.2.1(jiti@2.6.1)):
7275 dependencies:
7218 - '@vue/compiler-sfc': 3.5.32
7276 + '@vue/compiler-sfc': 3.5.33
7277 eslint: 10.2.1(jiti@2.6.1)
7278
7279 eslint-scope@9.1.2:
@@ -7364,7 +7422,7 @@ snapshots:
7422 dependencies:
7423 path-expression-matcher: 1.5.0
7424
7367 - fast-xml-parser@5.7.1:
7425 + fast-xml-parser@5.7.2:
7426 dependencies:
7427 '@nodable/entities': 2.1.0
7428 fast-xml-builder: 1.1.5
@@ -8344,10 +8402,10 @@ snapshots:
8402 arrify: 2.0.1
8403 minimatch: 3.1.5
8404
8347 - naive-ui@2.44.1(vue@3.5.32(typescript@5.9.3)):
8405 + naive-ui@2.44.1(vue@3.5.33(typescript@5.9.3)):
8406 dependencies:
8407 '@css-render/plugin-bem': 0.15.14(css-render@0.15.14)
8350 - '@css-render/vue3-ssr': 0.15.14(vue@3.5.32(typescript@5.9.3))
8408 + '@css-render/vue3-ssr': 0.15.14(vue@3.5.33(typescript@5.9.3))
8409 '@types/lodash': 4.17.24
8410 '@types/lodash-es': 4.17.12
8411 async-validator: 4.2.5
@@ -8361,10 +8419,10 @@ snapshots:
8419 lodash-es: 4.18.1
8420 seemly: 0.3.10
8421 treemate: 0.3.11
8364 - vdirs: 0.1.8(vue@3.5.32(typescript@5.9.3))
8365 - vooks: 0.2.12(vue@3.5.32(typescript@5.9.3))
8366 - vue: 3.5.32(typescript@5.9.3)
8367 - vueuc: 0.4.65(vue@3.5.32(typescript@5.9.3))
8422 + vdirs: 0.1.8(vue@3.5.33(typescript@5.9.3))
8423 + vooks: 0.2.12(vue@3.5.33(typescript@5.9.3))
8424 + vue: 3.5.33(typescript@5.9.3)
8425 + vueuc: 0.4.65(vue@3.5.33(typescript@5.9.3))
8426
8427 nanoid@3.3.11: {}
8428
@@ -8541,17 +8599,17 @@ snapshots:
8599
8600 pidtree@0.6.0: {}
8601
8544 - pinia-plugin-persistedstate@4.7.1(@nuxt/kit@3.21.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))):
8602 + pinia-plugin-persistedstate@4.7.1(@nuxt/kit@3.21.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3))):
8603 dependencies:
8604 defu: 6.1.7
8605 optionalDependencies:
8606 '@nuxt/kit': 3.21.2
8549 - pinia: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
8607 + pinia: 3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3))
8608
8551 - pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)):
8609 + pinia@3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3)):
8610 dependencies:
8611 '@vue/devtools-api': 7.7.9
8554 - vue: 3.5.32(typescript@5.9.3)
8612 + vue: 3.5.33(typescript@5.9.3)
8613 optionalDependencies:
8614 typescript: 5.9.3
8615
@@ -8597,7 +8655,7 @@ snapshots:
8655
8656 prelude-ls@1.2.1: {}
8657
8600 - prettier-plugin-tailwindcss@0.7.2(prettier@3.8.3):
8658 + prettier-plugin-tailwindcss@0.7.3(prettier@3.8.3):
8659 dependencies:
8660 prettier: 3.8.3
8661
@@ -8998,7 +9056,7 @@ snapshots:
9056
9057 tagged-tag@1.0.0: {}
9058
9001 - tailwindcss@4.2.2: {}
9059 + tailwindcss@4.2.4: {}
9060
9061 tapable@2.3.2: {}
9062
@@ -9202,10 +9260,10 @@ snapshots:
9260
9261 validator@13.15.35: {}
9262
9205 - vdirs@0.1.8(vue@3.5.32(typescript@5.9.3)):
9263 + vdirs@0.1.8(vue@3.5.33(typescript@5.9.3)):
9264 dependencies:
9265 evtd: 0.2.4
9208 - vue: 3.5.32(typescript@5.9.3)
9266 + vue: 3.5.33(typescript@5.9.3)
9267
9268 vfile-message@4.0.3:
9269 dependencies:
@@ -9255,9 +9313,9 @@ snapshots:
9313 transitivePeerDependencies:
9314 - supports-color
9315
9258 - vite-plugin-vue-devtools@8.1.1(@nuxt/kit@3.21.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
9316 + vite-plugin-vue-devtools@8.1.1(@nuxt/kit@3.21.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.5.33(typescript@5.9.3)):
9317 dependencies:
9260 - '@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
9318 + '@vue/devtools-core': 8.1.1(vue@3.5.33(typescript@5.9.3))
9319 '@vue/devtools-kit': 8.1.1
9320 '@vue/devtools-shared': 8.1.1
9321 sirv: 3.0.2
@@ -9284,11 +9342,11 @@ snapshots:
9342 transitivePeerDependencies:
9343 - supports-color
9344
9287 - vite-svg-loader@5.1.1(vue@3.5.32(typescript@5.9.3)):
9345 + vite-svg-loader@5.1.1(vue@3.5.33(typescript@5.9.3)):
9346 dependencies:
9347 debug: 4.4.3
9348 svgo: 3.3.3
9291 - vue: 3.5.32(typescript@5.9.3)
9349 + vue: 3.5.33(typescript@5.9.3)
9350 transitivePeerDependencies:
9351 - supports-color
9352
@@ -9308,15 +9366,15 @@ snapshots:
9366 sass: 1.99.0
9367 yaml: 2.8.3
9368
9311 - vitest@4.1.4(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)):
9369 + vitest@4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3)):
9370 dependencies:
9313 - '@vitest/expect': 4.1.4
9314 - '@vitest/mocker': 4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))
9315 - '@vitest/pretty-format': 4.1.4
9316 - '@vitest/runner': 4.1.4
9317 - '@vitest/snapshot': 4.1.4
9318 - '@vitest/spy': 4.1.4
9319 - '@vitest/utils': 4.1.4
9371 + '@vitest/expect': 4.1.5
9372 + '@vitest/mocker': 4.1.5(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(yaml@2.8.3))
9373 + '@vitest/pretty-format': 4.1.5
9374 + '@vitest/runner': 4.1.5
9375 + '@vitest/snapshot': 4.1.5
9376 + '@vitest/spy': 4.1.5
9377 + '@vitest/utils': 4.1.5
9378 es-module-lexer: 2.0.0
9379 expect-type: 1.3.0
9380 magic-string: 0.30.21
@@ -9338,30 +9396,30 @@ snapshots:
9396
9397 void-elements@3.1.0: {}
9398
9341 - vooks@0.2.12(vue@3.5.32(typescript@5.9.3)):
9399 + vooks@0.2.12(vue@3.5.33(typescript@5.9.3)):
9400 dependencies:
9401 evtd: 0.2.4
9344 - vue: 3.5.32(typescript@5.9.3)
9402 + vue: 3.5.33(typescript@5.9.3)
9403
9404 vscode-uri@3.1.0: {}
9405
9348 - vue-advanced-cropper@2.8.9(vue@3.5.32(typescript@5.9.3)):
9406 + vue-advanced-cropper@2.8.9(vue@3.5.33(typescript@5.9.3)):
9407 dependencies:
9408 classnames: 2.5.1
9409 debounce: 1.2.1
9410 easy-bem: 1.1.1
9353 - vue: 3.5.32(typescript@5.9.3)
9411 + vue: 3.5.33(typescript@5.9.3)
9412
9355 - vue-codemirror@6.1.1(codemirror@6.0.2)(vue@3.5.32(typescript@5.9.3)):
9413 + vue-codemirror@6.1.1(codemirror@6.0.2)(vue@3.5.33(typescript@5.9.3)):
9414 dependencies:
9415 '@codemirror/commands': 6.10.3
9416 '@codemirror/language': 6.12.3
9417 '@codemirror/state': 6.6.0
9418 '@codemirror/view': 6.41.1
9419 codemirror: 6.0.2
9362 - vue: 3.5.32(typescript@5.9.3)
9420 + vue: 3.5.33(typescript@5.9.3)
9421
9364 - vue-component-type-helpers@2.2.12: {}
9422 + vue-component-type-helpers@3.2.7: {}
9423
9424 vue-eslint-parser@10.4.0(eslint@10.2.1(jiti@2.6.1)):
9425 dependencies:
@@ -9375,23 +9433,23 @@ snapshots:
9433 transitivePeerDependencies:
9434 - supports-color
9435
9378 - vue-highlight-words@3.0.1(vue@3.5.32(typescript@5.9.3)):
9436 + vue-highlight-words@3.0.1(vue@3.5.33(typescript@5.9.3)):
9437 dependencies:
9438 highlight-words-core: 1.2.3
9381 - vue: 3.5.32(typescript@5.9.3)
9439 + vue: 3.5.33(typescript@5.9.3)
9440
9383 - vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)):
9441 + vue-i18n@11.4.0(vue@3.5.33(typescript@5.9.3)):
9442 dependencies:
9385 - '@intlify/core-base': 11.3.2
9386 - '@intlify/devtools-types': 11.3.2
9387 - '@intlify/shared': 11.3.2
9443 + '@intlify/core-base': 11.4.0
9444 + '@intlify/devtools-types': 11.4.0
9445 + '@intlify/shared': 11.4.0
9446 '@vue/devtools-api': 6.6.4
9389 - vue: 3.5.32(typescript@5.9.3)
9447 + vue: 3.5.33(typescript@5.9.3)
9448
9391 - vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
9449 + vue-router@5.0.6(@vue/compiler-sfc@3.5.33)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3)))(vue@3.5.33(typescript@5.9.3)):
9450 dependencies:
9451 '@babel/generator': 7.29.1
9394 - '@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3))
9452 + '@vue-macros/common': 3.1.2(vue@3.5.33(typescript@5.9.3))
9453 '@vue/devtools-api': 8.1.1
9454 ast-walker-scope: 0.8.3
9455 chokidar: 5.0.0
@@ -9406,15 +9464,15 @@ snapshots:
9464 tinyglobby: 0.2.16
9465 unplugin: 3.0.0
9466 unplugin-utils: 0.3.1
9409 - vue: 3.5.32(typescript@5.9.3)
9467 + vue: 3.5.33(typescript@5.9.3)
9468 yaml: 2.8.3
9469 optionalDependencies:
9412 - '@vue/compiler-sfc': 3.5.32
9413 - pinia: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
9470 + '@vue/compiler-sfc': 3.5.33
9471 + pinia: 3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3))
9472
9415 - vue-sjv@0.0.6(vue@3.5.32(typescript@5.9.3)):
9473 + vue-sjv@0.0.6(vue@3.5.33(typescript@5.9.3)):
9474 dependencies:
9417 - vue: 3.5.32(typescript@5.9.3)
9475 + vue: 3.5.33(typescript@5.9.3)
9476
9477 vue-tsc@3.2.7(typescript@5.9.3):
9478 dependencies:
@@ -9422,40 +9480,40 @@ snapshots:
9480 '@vue/language-core': 3.2.7
9481 typescript: 5.9.3
9482
9425 - vue3-apexcharts@1.11.1(apexcharts@5.10.6)(vue@3.5.32(typescript@5.9.3)):
9483 + vue3-apexcharts@1.11.1(apexcharts@5.10.6)(vue@3.5.33(typescript@5.9.3)):
9484 dependencies:
9485 apexcharts: 5.10.6
9428 - vue: 3.5.32(typescript@5.9.3)
9486 + vue: 3.5.33(typescript@5.9.3)
9487
9430 - vue3-marquee@4.2.2(vue@3.5.32(typescript@5.9.3)):
9488 + vue3-marquee@4.2.2(vue@3.5.33(typescript@5.9.3)):
9489 dependencies:
9432 - vue: 3.5.32(typescript@5.9.3)
9490 + vue: 3.5.33(typescript@5.9.3)
9491
9434 - vue@3.5.32(typescript@5.9.3):
9492 + vue@3.5.33(typescript@5.9.3):
9493 dependencies:
9436 - '@vue/compiler-dom': 3.5.32
9437 - '@vue/compiler-sfc': 3.5.32
9438 - '@vue/runtime-dom': 3.5.32
9439 - '@vue/server-renderer': 3.5.32(vue@3.5.32(typescript@5.9.3))
9440 - '@vue/shared': 3.5.32
9494 + '@vue/compiler-dom': 3.5.33
9495 + '@vue/compiler-sfc': 3.5.33
9496 + '@vue/runtime-dom': 3.5.33
9497 + '@vue/server-renderer': 3.5.33(vue@3.5.33(typescript@5.9.3))
9498 + '@vue/shared': 3.5.33
9499 optionalDependencies:
9500 typescript: 5.9.3
9501
9444 - vuedraggable@4.1.0(vue@3.5.32(typescript@5.9.3)):
9502 + vuedraggable@4.1.0(vue@3.5.33(typescript@5.9.3)):
9503 dependencies:
9504 sortablejs: 1.14.0
9447 - vue: 3.5.32(typescript@5.9.3)
9505 + vue: 3.5.33(typescript@5.9.3)
9506
9449 - vueuc@0.4.65(vue@3.5.32(typescript@5.9.3)):
9507 + vueuc@0.4.65(vue@3.5.33(typescript@5.9.3)):
9508 dependencies:
9451 - '@css-render/vue3-ssr': 0.15.14(vue@3.5.32(typescript@5.9.3))
9509 + '@css-render/vue3-ssr': 0.15.14(vue@3.5.33(typescript@5.9.3))
9510 '@juggle/resize-observer': 3.4.0
9511 css-render: 0.15.14
9512 evtd: 0.2.4
9513 seemly: 0.3.10
9456 - vdirs: 0.1.8(vue@3.5.32(typescript@5.9.3))
9457 - vooks: 0.2.12(vue@3.5.32(typescript@5.9.3))
9458 - vue: 3.5.32(typescript@5.9.3)
9514 + vdirs: 0.1.8(vue@3.5.33(typescript@5.9.3))
9515 + vooks: 0.2.12(vue@3.5.33(typescript@5.9.3))
9516 + vue: 3.5.33(typescript@5.9.3)
9517
9518 w3c-keyname@2.2.8: {}
9519
@@ -9465,7 +9523,7 @@ snapshots:
9523
9524 wait-on@9.0.5(debug@4.4.3):
9525 dependencies:
9468 - axios: 1.15.1(debug@4.4.3)
9526 + axios: 1.15.2(debug@4.4.3)
9527 joi: 18.1.2
9528 lodash: 4.18.1
9529 minimist: 1.2.8
frontend/src/api/endpoints/incidentManagement/caseTemplates.ts new
+126
@@ -0,0 +1,126 @@
1 +import type { FlaskBaseResponse } from "@/types/flask.d"
2 +import type {
3 + CaseEvent,
4 + CaseTask,
5 + CaseTaskCreatePayload,
6 + CaseTaskUpdatePayload,
7 + CaseTemplate,
8 + CaseTemplateCreatePayload,
9 + CaseTemplateTask,
10 + CaseTemplateTaskCreatePayload,
11 + CaseTemplateTaskUpdatePayload,
12 + CaseTemplateUpdatePayload
13 +} from "@/types/incidentManagement/caseTemplates.d"
14 +import { HttpClient } from "../../httpClient"
15 +
16 +// ---------------------------------------------------------------------------
17 +// Case template management (admin/analyst only — backend gates by scope)
18 +// ---------------------------------------------------------------------------
19 +
20 +export interface CaseTemplateListFilters {
21 + customerCode?: string
22 + source?: string
23 + includeGlobal?: boolean
24 +}
25 +
26 +export default {
27 + listTemplates(filters: CaseTemplateListFilters = {}) {
28 + const params: Record<string, string | boolean> = {}
29 + if (filters.customerCode !== undefined) params.customer_code = filters.customerCode
30 + if (filters.source !== undefined) params.source = filters.source
31 + if (filters.includeGlobal !== undefined) params.include_global = filters.includeGlobal
32 +
33 + return HttpClient.get<FlaskBaseResponse & { templates: CaseTemplate[] }>(`/incidents/case_templates`, {
34 + params
35 + })
36 + },
37 + getTemplate(templateId: number) {
38 + return HttpClient.get<FlaskBaseResponse & { template: CaseTemplate | null }>(
39 + `/incidents/case_templates/${templateId}`
40 + )
41 + },
42 + createTemplate(payload: CaseTemplateCreatePayload) {
43 + return HttpClient.post<FlaskBaseResponse & { template: CaseTemplate | null }>(
44 + `/incidents/case_templates`,
45 + payload
46 + )
47 + },
48 + updateTemplate(templateId: number, payload: CaseTemplateUpdatePayload) {
49 + return HttpClient.patch<FlaskBaseResponse & { template: CaseTemplate | null }>(
50 + `/incidents/case_templates/${templateId}`,
51 + payload
52 + )
53 + },
54 + deleteTemplate(templateId: number) {
55 + return HttpClient.delete<FlaskBaseResponse & { template: CaseTemplate | null }>(
56 + `/incidents/case_templates/${templateId}`
57 + )
58 + },
59 +
60 + // Template tasks
61 + addTemplateTask(templateId: number, payload: CaseTemplateTaskCreatePayload) {
62 + return HttpClient.post<FlaskBaseResponse & { task: CaseTemplateTask | null }>(
63 + `/incidents/case_templates/${templateId}/tasks`,
64 + payload
65 + )
66 + },
67 + updateTemplateTask(taskId: number, payload: CaseTemplateTaskUpdatePayload) {
68 + return HttpClient.patch<FlaskBaseResponse & { task: CaseTemplateTask | null }>(
69 + `/incidents/case_templates/tasks/${taskId}`,
70 + payload
71 + )
72 + },
73 + deleteTemplateTask(taskId: number) {
74 + return HttpClient.delete<FlaskBaseResponse & { task: CaseTemplateTask | null }>(
75 + `/incidents/case_templates/tasks/${taskId}`
76 + )
77 + },
78 + reorderTemplateTasks(templateId: number, orderedTaskIds: number[]) {
79 + return HttpClient.post<FlaskBaseResponse & { template: CaseTemplate | null }>(
80 + `/incidents/case_templates/${templateId}/tasks/reorder`,
81 + orderedTaskIds
82 + )
83 + },
84 +
85 + // ---------------------------------------------------------------------------
86 + // Per-case tasks (visible to admin/analyst/customer_user; writes admin/analyst only)
87 + // ---------------------------------------------------------------------------
88 + listCaseTasks(caseId: number) {
89 + return HttpClient.get<FlaskBaseResponse & { tasks: CaseTask[] }>(
90 + `/incidents/db_operations/case/${caseId}/tasks`
91 + )
92 + },
93 + addCaseTask(caseId: number, payload: CaseTaskCreatePayload) {
94 + return HttpClient.post<FlaskBaseResponse & { task: CaseTask | null }>(
95 + `/incidents/db_operations/case/${caseId}/tasks`,
96 + payload
97 + )
98 + },
99 + updateCaseTask(taskId: number, payload: CaseTaskUpdatePayload, signal?: AbortSignal) {
100 + return HttpClient.patch<FlaskBaseResponse & { task: CaseTask | null }>(
101 + `/incidents/db_operations/case/tasks/${taskId}`,
102 + payload,
103 + { signal }
104 + )
105 + },
106 + deleteCaseTask(taskId: number) {
107 + return HttpClient.delete<FlaskBaseResponse & { task: CaseTask | null }>(
108 + `/incidents/db_operations/case/tasks/${taskId}`
109 + )
110 + },
111 + applyTemplateToCase(caseId: number, templateId: number) {
112 + return HttpClient.post<FlaskBaseResponse & { tasks_added: number }>(
113 + `/incidents/db_operations/case/${caseId}/apply-template/${templateId}`
114 + )
115 + },
116 +
117 + // ---------------------------------------------------------------------------
118 + // Timeline (read-only for everyone with case access)
119 + // ---------------------------------------------------------------------------
120 + getCaseTimeline(caseId: number, limit = 500, offset = 0) {
121 + return HttpClient.get<FlaskBaseResponse & { case_id: number; events: CaseEvent[] }>(
122 + `/incidents/db_operations/case/${caseId}/timeline`,
123 + { params: { limit, offset } }
124 + )
125 + }
126 +}
frontend/src/api/endpoints/incidentManagement/cases.ts
+16 -9
@@ -68,15 +68,18 @@ export default {
68 getCase(caseId: number) {
69 return HttpClient.get<FlaskBaseResponse & { cases: Case[] }>(`/incidents/db_operations/case/${caseId}`)
70 },
71 - createCase(payload: CasePayload) {
72 - return HttpClient.post<FlaskBaseResponse & { case: Case }>(`/incidents/db_operations/case/create`, payload)
71 + createCase(payload: CasePayload, params: Record<string, number | string | boolean> = {}) {
72 + return HttpClient.post<FlaskBaseResponse & { case: Case }>(`/incidents/db_operations/case/create`, payload, {
73 + params
74 + })
75 },
74 - createCaseFromAlert(alertId: number) {
76 + createCaseFromAlert(alertId: number, params: Record<string, number | string | boolean> = {}) {
77 return HttpClient.post<FlaskBaseResponse & { case_alert_link: { case_id: number; alert_id: number } }>(
78 `/incidents/db_operations/case/from-alert`,
79 {
80 alert_id: alertId
79 - }
81 + },
82 + { params }
83 )
84 },
85 /** @deprecated in favor of multiLinkCase */
@@ -104,11 +107,15 @@ export default {
107 case_id: caseId
108 })
109 },
107 - updateCaseStatus(caseId: number, status: CaseStatus) {
108 - return HttpClient.put<FlaskBaseResponse>(`/incidents/db_operations/case/status`, {
109 - case_id: caseId,
110 - status
111 - })
110 + updateCaseStatus(caseId: number, status: CaseStatus, force = false) {
111 + return HttpClient.put<FlaskBaseResponse>(
112 + `/incidents/db_operations/case/status`,
113 + {
114 + case_id: caseId,
115 + status
116 + },
117 + { params: force ? { force: true } : {} }
118 + )
119 },
120 updateCaseAssignedUser(caseId: number, user: string) {
121 return HttpClient.put<FlaskBaseResponse>(`/incidents/db_operations/case/assigned-to`, {
frontend/src/api/endpoints/incidentManagement/index.ts
+2
@@ -1,6 +1,7 @@
1 import aiTriggers from "./aiTriggers"
2 import alerts from "./alerts"
3 import cases from "./cases"
4 +import caseTemplates from "./caseTemplates"
5 import exclusionRules from "./exclusionRules"
6 import notification from "./notification"
7 import sources from "./sources"
@@ -8,6 +9,7 @@ import sources from "./sources"
9 export default {
10 aiTriggers,
11 alerts,
12 + caseTemplates,
13 cases,
14 exclusionRules,
15 notification,
frontend/src/app-layouts/common/Navbar/items.tsx
+13
@@ -183,6 +183,19 @@ export default function getItems(): MenuMixedOption[] {
183 { default: () => "Cases" }
184 ),
185 key: "IncidentManagement-Cases"
186 + },
187 + {
188 + label: () =>
189 + h(
190 + RouterLink,
191 + {
192 + to: {
193 + name: "IncidentManagement-CaseTemplates"
194 + }
195 + },
196 + { default: () => "Case Templates" }
197 + ),
198 + key: "IncidentManagement-CaseTemplates"
199 }
200 /*
201 {
frontend/src/components/aiAnalyst/AlertReportCompare/AlertReportCompare.vue
+3 -1
@@ -53,6 +53,7 @@ import type { AiAnalystReport } from "@/types/aiAnalyst.d"
53 import { NEmpty, NFormItem, NSelect, NSpin, useMessage } from "naive-ui"
54 import { computed, h, ref, toRefs, watch } from "vue"
55 import Api from "@/api"
56 +import { useSettingsStore } from "@/stores/settings"
57 import { formatDate } from "@/utils/format"
58 import ReportColumn from "./AlertReportCompareColumn.vue"
59
@@ -65,6 +66,7 @@ const { alertId, currentReportId } = toRefs(props)
66
67 const message = useMessage()
68 const loading = ref(false)
69 +const dFormats = useSettingsStore().dateFormat
70 const reports = ref<AiAnalystReport[]>([])
71
72 const idA = ref<number | null>(null)
@@ -87,7 +89,7 @@ const reportB = computed(() => reports.value.find(r => r.id === idB.value) ?? nu
89 // Render option with created_at + severity so the picker shows meaningful
90 // distinctions between runs rather than just bare IDs.
91 function renderOption(option: { label: string; value: number; severity?: string | null; created_at?: string }) {
90 - const ts = option.created_at ? String(formatDate(option.created_at, "MMM D, YYYY HH:mm")) : ""
92 + const ts = option.created_at ? String(formatDate(option.created_at, dFormats.datetime)) : ""
93 const sev = option.severity ? ` · ${option.severity}` : ""
94 return h("div", { class: "flex flex-col leading-none py-2" }, [
95 h("span", `#${option.label}${sev}`),
frontend/src/components/aiAnalyst/Feedback/PalaceConsolidationDrawer.vue
+5 -1
@@ -18,7 +18,11 @@
18 <div v-else-if="data" class="flex flex-col gap-4">
19 <!-- Summary tiles -->
20 <div class="grid grid-cols-2 gap-3 md:grid-cols-4">
21 - <MetricTile label="Active" :value="data.total_lessons.toString()" :sub="`${data.total_pending} pending`" />
21 + <MetricTile
22 + label="Active"
23 + :value="data.total_lessons.toString()"
24 + :sub="`${data.total_pending} pending`"
25 + />
26 <MetricTile
27 label="Durable"
28 :value="data.total_durable.toString()"
frontend/src/components/auth/TotpForm.vue
+2 -2
@@ -37,7 +37,7 @@
37 {{ showBackupInput ? "Use backup code" : "Verify" }}
38 </n-button>
39
40 - <div class="mt-4 flex items-center justify-between">
40 + <div class="mt-4 flex items-center justify-between gap-2">
41 <n-button text size="small" @click="cancel2fa()">← Back to login</n-button>
42 <n-button text size="small" @click="showBackupInput = !showBackupInput">
43 {{ showBackupInput ? "Use authenticator code" : "Use a backup code instead" }}
@@ -50,7 +50,7 @@
50 import type { InputOtpInst } from "naive-ui"
51 import type { TOTPValidateRequest } from "@/api/endpoints/totp"
52 import { NButton, NCollapseTransition, NInput, NInputOtp, useMessage } from "naive-ui"
53 -import { computed, nextTick, onMounted, ref, watch } from "vue"
53 +import { computed, onMounted, ref, watch } from "vue"
54 import { useRouter } from "vue-router"
55 import { useAuthStore } from "@/stores/auth"
56
frontend/src/components/incidentManagement/caseTemplates/CaseTemplateEditor.vue new
+433
@@ -0,0 +1,433 @@
1 +<template>
2 + <n-form ref="formRef" :model="form" :rules="formRules" label-placement="top" :disabled="saving">
3 + <n-form-item label="Name" path="name">
4 + <n-input v-model:value="form.name" placeholder="e.g., Wazuh — Default" />
5 + </n-form-item>
6 +
7 + <n-form-item label="Description" path="description">
8 + <n-input
9 + v-model:value="form.description"
10 + type="textarea"
11 + placeholder="What this template is for"
12 + :autosize="{ minRows: 2, maxRows: 4 }"
13 + />
14 + </n-form-item>
15 +
16 + <div class="grid grid-cols-2 gap-4">
17 + <n-form-item label="Customer code" path="customer_code" :show-feedback="false">
18 + <n-select
19 + v-model:value="form.customer_code"
20 + :options="customersOptions"
21 + placeholder="Leave empty for global"
22 + :loading="loadingCustomers"
23 + filterable
24 + clearable
25 + :consistent-menu-width="false"
26 + />
27 + </n-form-item>
28 + <n-form-item label="Alert source" path="source" :show-feedback="false">
29 + <n-select
30 + v-model:value="form.source"
31 + :options="sourcesOptions"
32 + :consistent-menu-width="false"
33 + placeholder="e.g., wazuh (leave empty for any)"
34 + filterable
35 + clearable
36 + :loading="loadingConfiguredSources"
37 + />
38 + </n-form-item>
39 + </div>
40 +
41 + <n-form-item path="is_default">
42 + <n-checkbox v-model:checked="form.is_default">Default for this (customer, source) scope</n-checkbox>
43 + </n-form-item>
44 +
45 + <n-card size="small" title="Tasks" content-class="flex flex-col gap-3">
46 + <template #header-extra>
47 + <div
48 + v-if="taskSaving"
49 + class="text-secondary text-xs opacity-0 transition-opacity duration-300"
50 + :class="{ 'animate-pulse opacity-100': taskSaving }"
51 + >
52 + saving...
53 + </div>
54 + </template>
55 + <p v-if="props.template == null" class="text-xs">
56 + Add at least one task. You can edit / reorder tasks after the template is created.
57 + </p>
58 + <p v-else class="text-xs">
59 + Tasks below are saved immediately on add / edit / delete. Editing the template does NOT mutate task
60 + snapshots already attached to real cases.
61 + </p>
62 +
63 + <div class="flex flex-col gap-2">
64 + <CardEntity v-for="(task, idx) in tasks" :key="task._key" embedded size="small">
65 + <div class="flex flex-col gap-2">
66 + <div class="flex items-center gap-2">
67 + <n-input
68 + v-model:value="task.title"
69 + size="small"
70 + placeholder="Task title"
71 + class="flex-1"
72 + @blur="saveTask(idx)"
73 + />
74 + <n-checkbox v-model:checked="task.mandatory" @update:checked="saveTask(idx)">
75 + mandatory
76 + </n-checkbox>
77 + <n-button-group v-if="tasks.length > 1" size="tiny">
78 + <n-button :disabled="idx === 0" @click="moveTask(idx, -1)">
79 + <template #icon><Icon name="carbon:arrow-up" /></template>
80 + </n-button>
81 + <n-button :disabled="idx === tasks.length - 1" @click="moveTask(idx, 1)">
82 + <template #icon><Icon name="carbon:arrow-down" /></template>
83 + </n-button>
84 + </n-button-group>
85 + <n-button
86 + v-if="tasks.length > 1"
87 + size="tiny"
88 + type="error"
89 + quaternary
90 + @click="deleteTask(idx)"
91 + >
92 + <template #icon><Icon name="carbon:trash-can" /></template>
93 + </n-button>
94 + </div>
95 + <n-input
96 + v-model:value="task.description"
97 + size="small"
98 + placeholder="Description (optional)"
99 + :autosize="{ minRows: 1, maxRows: 3 }"
100 + type="textarea"
101 + clearable
102 + @blur="saveTask(idx)"
103 + />
104 + <n-input
105 + v-model:value="task.guidelines"
106 + size="small"
107 + placeholder="Guidelines / best practices (optional)"
108 + :autosize="{ minRows: 1, maxRows: 5 }"
109 + type="textarea"
110 + clearable
111 + @blur="saveTask(idx)"
112 + />
113 + </div>
114 + </CardEntity>
115 +
116 + <n-button size="small" dashed @click="addTask">
117 + <template #icon><Icon name="carbon:add" /></template>
118 + Add task
119 + </n-button>
120 + </div>
121 + </n-card>
122 +
123 + <div class="mt-4 flex justify-end gap-2">
124 + <n-button @click="emit('cancel')">Cancel</n-button>
125 + <n-button type="primary" :loading="saving" @click="handleSave">
126 + {{ props.template ? "Save changes" : "Create template" }}
127 + </n-button>
128 + </div>
129 + </n-form>
130 +</template>
131 +
132 +<script setup lang="ts">
133 +import type { FormInst, FormRules } from "naive-ui"
134 +import type { ApiError } from "@/types/common"
135 +import type { Customer } from "@/types/customers"
136 +import type { CaseTemplate } from "@/types/incidentManagement/caseTemplates.d"
137 +import type { SourceName } from "@/types/incidentManagement/sources"
138 +import { NButton, NButtonGroup, NCard, NCheckbox, NForm, NFormItem, NInput, NSelect, useMessage } from "naive-ui"
139 +import { computed, onBeforeMount, ref, watch } from "vue"
140 +import Api from "@/api"
141 +import CardEntity from "@/components/common/cards/CardEntity.vue"
142 +import Icon from "@/components/common/Icon.vue"
143 +import { getApiErrorMessage } from "@/utils"
144 +
145 +interface DraftTask {
146 + _key: string // stable client-side key for v-for
147 + id?: number // present when persisted (template_task_id from backend)
148 + title: string
149 + description: string
150 + guidelines: string
151 + mandatory: boolean
152 + order_index: number
153 +}
154 +
155 +interface FormModel {
156 + name: string | null
157 + description: string | null
158 + customer_code: string | null
159 + source: string | null
160 + is_default: boolean
161 +}
162 +
163 +const props = defineProps<{
164 + template: CaseTemplate | null
165 +}>()
166 +
167 +const emit = defineEmits<{
168 + (e: "saved", template: CaseTemplate): void
169 + (e: "cancel"): void
170 +}>()
171 +
172 +const message = useMessage()
173 +const formRef = ref<FormInst | null>(null)
174 +const saving = ref(false)
175 +const taskSaving = ref(false)
176 +
177 +const loadingCustomers = ref(false)
178 +const customersList = ref<Customer[]>([])
179 +const customersOptions = computed(() =>
180 + customersList.value.map(o => ({ label: `#${o.customer_code} - ${o.customer_name}`, value: o.customer_code }))
181 +)
182 +
183 +const loadingConfiguredSources = ref(false)
184 +const configuredSourcesList = ref<SourceName[]>([])
185 +const sourcesOptions = computed(() => configuredSourcesList.value.map(o => ({ label: o, value: o })))
186 +
187 +const form = ref<FormModel>({
188 + name: null,
189 + description: null,
190 + customer_code: null,
191 + source: null,
192 + is_default: false
193 +})
194 +const formRules: FormRules = {
195 + name: { required: true, message: "Name is required", trigger: "blur" }
196 +}
197 +
198 +const tasks = ref<DraftTask[]>([])
199 +
200 +let keyCounter = 0
201 +function nextKey() {
202 + keyCounter += 1
203 + return `t${Date.now()}-${keyCounter}`
204 +}
205 +
206 +function loadFromTemplate(t: CaseTemplate | null) {
207 + if (t) {
208 + form.value = {
209 + name: t.name,
210 + description: t.description ?? "",
211 + customer_code: t.customer_code ?? "",
212 + source: t.source ?? "",
213 + is_default: t.is_default
214 + }
215 + tasks.value = (t.tasks ?? []).map(task => ({
216 + _key: nextKey(),
217 + id: task.id,
218 + title: task.title,
219 + description: task.description ?? "",
220 + guidelines: task.guidelines ?? "",
221 + mandatory: task.mandatory,
222 + order_index: task.order_index
223 + }))
224 + } else {
225 + form.value = { name: null, description: null, customer_code: null, source: null, is_default: false }
226 + tasks.value = [
227 + {
228 + _key: nextKey(),
229 + title: "",
230 + description: "",
231 + guidelines: "",
232 + mandatory: false,
233 + order_index: 0
234 + }
235 + ]
236 + }
237 +}
238 +
239 +function addTask() {
240 + tasks.value.push({
241 + _key: nextKey(),
242 + title: "",
243 + description: "",
244 + guidelines: "",
245 + mandatory: false,
246 + order_index: tasks.value.length
247 + })
248 +}
249 +
250 +async function deleteTask(idx: number) {
251 + const task = tasks.value[idx]
252 + // If the template hasn't been created yet, just drop the row.
253 + if (!props.template || task.id == null) {
254 + tasks.value.splice(idx, 1)
255 + return
256 + }
257 +
258 + taskSaving.value = true
259 + try {
260 + const res = await Api.incidentManagement.caseTemplates.deleteTemplateTask(task.id)
261 + if (res.data.success) {
262 + tasks.value.splice(idx, 1)
263 + } else {
264 + message.warning(res.data.message)
265 + }
266 + } catch (err) {
267 + message.error(getApiErrorMessage(err as ApiError) || "Failed to delete task")
268 + } finally {
269 + taskSaving.value = false
270 + }
271 +}
272 +
273 +async function moveTask(idx: number, delta: number) {
274 + const newIdx = idx + delta
275 +
276 + if (newIdx < 0 || newIdx >= tasks.value.length) return
277 +
278 + const moved = tasks.value.splice(idx, 1)[0]
279 + tasks.value.splice(newIdx, 0, moved)
280 + tasks.value.forEach((t, i) => (t.order_index = i))
281 +
282 + // If the template is persisted, push the reorder up to the backend.
283 + if (props.template) {
284 + const orderedIds = tasks.value.filter(t => t.id != null).map(t => t.id as number)
285 + if (orderedIds.length === tasks.value.length) {
286 + taskSaving.value = true
287 + try {
288 + await Api.incidentManagement.caseTemplates.reorderTemplateTasks(props.template.id, orderedIds)
289 + } catch (err) {
290 + message.error(getApiErrorMessage(err as ApiError) || "Failed to reorder tasks")
291 + } finally {
292 + taskSaving.value = false
293 + }
294 + }
295 + }
296 +}
297 +
298 +async function saveTask(idx: number) {
299 + if (!props.template) return // creation flow batches at submit time
300 +
301 + const draft = tasks.value[idx]
302 +
303 + if (!draft.title.trim()) return // skip empty drafts; user is still typing
304 +
305 + taskSaving.value = true
306 +
307 + const payload = {
308 + title: draft.title,
309 + description: draft.description || null,
310 + guidelines: draft.guidelines || null,
311 + mandatory: draft.mandatory,
312 + order_index: draft.order_index
313 + }
314 +
315 + try {
316 + if (draft.id == null) {
317 + const res = await Api.incidentManagement.caseTemplates.addTemplateTask(props.template.id, payload)
318 + if (res.data.success && res.data.task) {
319 + draft.id = res.data.task.id
320 + } else {
321 + message.warning(res.data.message)
322 + }
323 + } else {
324 + const res = await Api.incidentManagement.caseTemplates.updateTemplateTask(draft.id, payload)
325 + if (!res.data.success) message.warning(res.data.message)
326 + }
327 + } catch (err) {
328 + message.error(getApiErrorMessage(err as ApiError) || "Failed to save task")
329 + } finally {
330 + taskSaving.value = false
331 + }
332 +}
333 +
334 +async function handleSave() {
335 + try {
336 + await formRef.value?.validate()
337 + } catch {
338 + return
339 + }
340 +
341 + saving.value = true
342 +
343 + const payload = {
344 + name: form.value.name || "",
345 + description: form.value.description || null,
346 + customer_code: form.value.customer_code || null,
347 + source: form.value.source || null,
348 + is_default: form.value.is_default
349 + }
350 +
351 + try {
352 + if (props.template) {
353 + // Update flow — metadata only; task edits already streamed via saveTask.
354 + const res = await Api.incidentManagement.caseTemplates.updateTemplate(props.template.id, payload)
355 + if (res.data.success && res.data.template) {
356 + emit("saved", res.data.template)
357 + } else {
358 + message.warning(res.data.message)
359 + }
360 + } else {
361 + const cleanTasks = tasks.value
362 + .filter(t => t.title.trim().length > 0)
363 + .map(t => ({
364 + title: t.title,
365 + description: t.description || null,
366 + guidelines: t.guidelines || null,
367 + mandatory: t.mandatory,
368 + order_index: t.order_index
369 + }))
370 + const res = await Api.incidentManagement.caseTemplates.createTemplate({
371 + ...payload,
372 + tasks: cleanTasks
373 + })
374 + if (res.data.success && res.data.template) {
375 + emit("saved", res.data.template)
376 + } else {
377 + message.warning(res.data.message)
378 + }
379 + }
380 + } catch (err) {
381 + message.error(getApiErrorMessage(err as ApiError) || "Failed to save template")
382 + } finally {
383 + saving.value = false
384 + }
385 +}
386 +
387 +function getCustomers() {
388 + loadingCustomers.value = true
389 +
390 + Api.customers
391 + .getCustomers()
392 + .then(res => {
393 + if (res.data.success) {
394 + customersList.value = res.data?.customers || []
395 + } else {
396 + message.warning(res.data?.message || "An error occurred. Please try again later.")
397 + }
398 + })
399 + .catch(err => {
400 + message.error(getApiErrorMessage(err as ApiError) || "An error occurred. Please try again later.")
401 + })
402 + .finally(() => {
403 + loadingCustomers.value = false
404 + })
405 +}
406 +
407 +function getConfiguredSources() {
408 + loadingConfiguredSources.value = true
409 +
410 + Api.incidentManagement.sources
411 + .getConfiguredSources()
412 + .then(res => {
413 + if (res.data.success) {
414 + configuredSourcesList.value = res.data?.sources || []
415 + } else {
416 + message.warning(res.data?.message || "An error occurred. Please try again later.")
417 + }
418 + })
419 + .catch(err => {
420 + message.error(getApiErrorMessage(err as ApiError) || "An error occurred. Please try again later.")
421 + })
422 + .finally(() => {
423 + loadingConfiguredSources.value = false
424 + })
425 +}
426 +
427 +watch(() => props.template, loadFromTemplate, { immediate: true })
428 +
429 +onBeforeMount(() => {
430 + getCustomers()
431 + getConfiguredSources()
432 +})
433 +</script>
frontend/src/components/incidentManagement/caseTemplates/CaseTemplatesList.vue new
+367
@@ -0,0 +1,367 @@
1 +<template>
2 + <div class="case-templates-list flex flex-col gap-4">
3 + <!-- Header / actions -->
4 + <div class="flex flex-col gap-2">
5 + <div class="flex items-center gap-4">
6 + <h2>Case Templates</h2>
7 + <n-button size="small" secondary type="primary" @click="openCreate">
8 + <template #icon><Icon name="carbon:add" /></template>
9 + New template
10 + </n-button>
11 + </div>
12 + <p>
13 + Reusable investigation playbooks. Templates are matched to new cases by customer + alert source on case
14 + creation, with priority customer+source &gt; customer-only &gt; source-only &gt; global default.
15 + </p>
16 + </div>
17 +
18 + <!-- Filters -->
19 + <div class="@container mt-4 grid grid-cols-12 items-center gap-3">
20 + <n-input
21 + v-model:value="search"
22 + size="small"
23 + placeholder="Search by name or description"
24 + clearable
25 + class="col-span-full @3xl:col-span-4 @6xl:col-span-5"
26 + >
27 + <template #prefix><Icon name="carbon:search" /></template>
28 + </n-input>
29 + <n-select
30 + v-model:value="customerFilter"
31 + size="small"
32 + :options="customersOptions"
33 + placeholder="Customer code (blank = all)"
34 + :loading="loadingCustomers"
35 + filterable
36 + clearable
37 + class="col-span-full @lg:col-span-6 @3xl:col-span-3"
38 + :consistent-menu-width="false"
39 + />
40 + <n-select
41 + v-model:value="sourceFilter"
42 + :options="sourcesOptions"
43 + :consistent-menu-width="false"
44 + placeholder="Alert source (blank = all)"
45 + size="small"
46 + filterable
47 + clearable
48 + class="col-span-full @lg:col-span-6 @3xl:col-span-3 @6xl:col-span-2"
49 + :loading="loadingConfiguredSources"
50 + />
51 + <n-checkbox v-model:checked="includeGlobal" size="small" class="col-span-full @3xl:col-span-2">
52 + <div class="text-xs">Include global / source-agnostic</div>
53 + </n-checkbox>
54 + </div>
55 +
56 + <n-data-table :columns :data="filteredRows" :loading size="small" />
57 +
58 + <!-- Editor modal -->
59 + <n-modal
60 + v-model:show="showEditor"
61 + preset="card"
62 + :title="editing ? `Edit template — ${editing.name}` : 'New template'"
63 + display-directive="show"
64 + style="max-width: 720px"
65 + >
66 + <CaseTemplateEditor :template="editing" @saved="onTemplateSaved" @cancel="showEditor = false" />
67 + </n-modal>
68 + </div>
69 +</template>
70 +
71 +<script setup lang="tsx">
72 +import type { DataTableColumns } from "naive-ui"
73 +import type { ApiError } from "@/types/common"
74 +import type { Customer } from "@/types/customers"
75 +import type { CaseTemplate } from "@/types/incidentManagement/caseTemplates.d"
76 +import type { SourceName } from "@/types/incidentManagement/sources"
77 +import { useDebounceFn } from "@vueuse/core"
78 +import { NButton, NCheckbox, NDataTable, NInput, NModal, NSelect, NTag, useDialog, useMessage } from "naive-ui"
79 +import { computed, onBeforeMount, ref, watch } from "vue"
80 +import Api from "@/api"
81 +import Icon from "@/components/common/Icon.vue"
82 +import { useSettingsStore } from "@/stores/settings"
83 +import { getApiErrorMessage } from "@/utils"
84 +import { formatDate } from "@/utils/format"
85 +import CaseTemplateEditor from "./CaseTemplateEditor.vue"
86 +
87 +const message = useMessage()
88 +const dialog = useDialog()
89 +
90 +const dFormats = useSettingsStore().dateFormat
91 +const templates = ref<CaseTemplate[]>([])
92 +const loading = ref(false)
93 +const deletingId = ref<number | null>(null)
94 +const search = ref<string | null>(null)
95 +const customerFilter = ref<string | null>(null)
96 +const sourceFilter = ref<string | null>(null)
97 +const includeGlobal = ref(true)
98 +
99 +const loadingCustomers = ref(false)
100 +const customersList = ref<Customer[]>([])
101 +const customersOptions = computed(() =>
102 + customersList.value.map(o => ({ label: `#${o.customer_code} - ${o.customer_name}`, value: o.customer_code }))
103 +)
104 +
105 +const loadingConfiguredSources = ref(false)
106 +const configuredSourcesList = ref<SourceName[]>([])
107 +const sourcesOptions = computed(() => configuredSourcesList.value.map(o => ({ label: o, value: o })))
108 +
109 +const showEditor = ref(false)
110 +const editing = ref<CaseTemplate | null>(null)
111 +
112 +function renderCustomerCode(customerCode: string | null | undefined) {
113 + if (customerCode) {
114 + return <span class="font-mono">{customerCode}</span>
115 + }
116 +
117 + return <em class="text-tertiary">any</em>
118 +}
119 +
120 +function renderSource(source: string | null | undefined) {
121 + if (source) {
122 + return <span class="font-mono">{source}</span>
123 + }
124 +
125 + return <em class="text-tertiary">any</em>
126 +}
127 +
128 +const filteredRows = computed(() => {
129 + if (!search.value?.trim()) return templates.value
130 +
131 + const text = search.value.trim().toLowerCase()
132 +
133 + return templates.value.filter(
134 + t =>
135 + t.name.toLowerCase().includes(text) ||
136 + (t.description ?? "").toLowerCase().includes(text) ||
137 + (t.customer_code ?? "").toLowerCase().includes(text) ||
138 + (t.source ?? "").toLowerCase().includes(text)
139 + )
140 +})
141 +
142 +const columns: DataTableColumns<CaseTemplate> = [
143 + {
144 + title: "Name",
145 + key: "name",
146 + className: "whitespace-nowrap",
147 + render: row => (
148 + <div class="flex flex-col gap-1">
149 + <div class="flex items-center gap-2">
150 + <span class="font-medium whitespace-nowrap">{row.name}</span>
151 + {row.is_default && (
152 + <NTag size="tiny" type="info" bordered={false}>
153 + default
154 + </NTag>
155 + )}
156 + </div>
157 + {row.description && <span class="text-secondary text-xs">{row.description}</span>}
158 + </div>
159 + )
160 + },
161 + {
162 + title: "Scope",
163 + key: "scope",
164 + className: "whitespace-nowrap",
165 + render: row => (
166 + <div class="flex flex-col text-sm whitespace-nowrap">
167 + <span>
168 + <span class="text-secondary">customer: </span>
169 + {renderCustomerCode(row.customer_code)}
170 + </span>
171 + <span>
172 + <span class="text-secondary">source: </span>
173 + {renderSource(row.source)}
174 + </span>
175 + </div>
176 + )
177 + },
178 + {
179 + title: "Tasks",
180 + key: "tasks",
181 + width: 110,
182 + className: "whitespace-nowrap",
183 + render(row) {
184 + const total = row.tasks?.length ?? 0
185 + const mandatory = row.tasks?.filter(t => t.mandatory).length ?? 0
186 +
187 + return (
188 + <div class="flex flex-col text-sm whitespace-nowrap">
189 + <span>
190 + <span class="font-mono">{total}</span>
191 + {" total"}
192 + </span>
193 + {mandatory > 0 && (
194 + <span class="text-warning">
195 + <span class="font-mono">{mandatory}</span>
196 + {" mandatory"}
197 + </span>
198 + )}
199 + </div>
200 + )
201 + }
202 + },
203 + {
204 + title: "Created by",
205 + key: "created_by",
206 + className: "whitespace-nowrap",
207 + render: row => <span class="whitespace-nowrap">{row.created_by}</span>
208 + },
209 + {
210 + title: "Updated",
211 + key: "updated_at",
212 + className: "whitespace-nowrap",
213 + render: row => (
214 + <span class="font-mono text-xs whitespace-nowrap">{formatDate(row.updated_at, dFormats.datetime)}</span>
215 + )
216 + },
217 + {
218 + title: "Actions",
219 + key: "actions",
220 + width: 120,
221 + className: "whitespace-nowrap",
222 + render: row => (
223 + <div class="flex gap-2">
224 + <NButton
225 + size="small"
226 + secondary
227 + onClick={() => openEdit(row)}
228 + v-slots={{ icon: () => <Icon name="carbon:edit" size={14} /> }}
229 + >
230 + Edit
231 + </NButton>
232 + <NButton
233 + size="small"
234 + secondary
235 + type="error"
236 + loading={deletingId.value === row.id}
237 + onClick={() => confirmDelete(row)}
238 + v-slots={{ icon: () => <Icon name="carbon:trash-can" size={14} /> }}
239 + >
240 + Delete
241 + </NButton>
242 + </div>
243 + )
244 + }
245 +]
246 +
247 +const fetchTemplates = useDebounceFn(() => {
248 + loading.value = true
249 +
250 + Api.incidentManagement.caseTemplates
251 + .listTemplates({
252 + customerCode: customerFilter.value || undefined,
253 + source: sourceFilter.value || undefined,
254 + includeGlobal: includeGlobal.value
255 + })
256 + .then(res => {
257 + if (res.data.success) {
258 + templates.value = res.data.templates
259 + } else {
260 + message.warning(res.data.message)
261 + }
262 + })
263 + .catch(err => {
264 + message.error(getApiErrorMessage(err as ApiError) || "Failed to load templates")
265 + })
266 + .finally(() => {
267 + loading.value = false
268 + })
269 +}, 400)
270 +
271 +function openCreate() {
272 + editing.value = null
273 + showEditor.value = true
274 +}
275 +
276 +function openEdit(row: CaseTemplate) {
277 + editing.value = row
278 + showEditor.value = true
279 +}
280 +
281 +function onTemplateSaved() {
282 + showEditor.value = false
283 + fetchTemplates()
284 +}
285 +
286 +function confirmDelete(row: CaseTemplate) {
287 + dialog.warning({
288 + title: `Delete template "${row.name}" ?`,
289 + content:
290 + "Deleting the template removes its task definitions. Existing CaseTask snapshots on real cases are preserved (they're independent of the template).",
291 + positiveText: "Delete",
292 + negativeText: "Cancel",
293 + onPositiveClick: () => {
294 + deletingId.value = row.id
295 +
296 + Api.incidentManagement.caseTemplates
297 + .deleteTemplate(row.id)
298 + .then(res => {
299 + if (res.data.success) {
300 + message.success(`Deleted "${row.name}"`)
301 + fetchTemplates()
302 + } else {
303 + message.warning(res.data.message)
304 + }
305 + })
306 + .catch(err => {
307 + message.error(getApiErrorMessage(err as ApiError) || "Failed to delete template")
308 + })
309 + .finally(() => {
310 + deletingId.value = null
311 + })
312 + }
313 + })
314 +}
315 +
316 +function getCustomers() {
317 + loadingCustomers.value = true
318 +
319 + Api.customers
320 + .getCustomers()
321 + .then(res => {
322 + if (res.data.success) {
323 + customersList.value = res.data?.customers || []
324 + } else {
325 + message.warning(res.data?.message || "An error occurred. Please try again later.")
326 + }
327 + })
328 + .catch(err => {
329 + message.error(getApiErrorMessage(err as ApiError) || "An error occurred. Please try again later.")
330 + })
331 + .finally(() => {
332 + loadingCustomers.value = false
333 + })
334 +}
335 +
336 +function getConfiguredSources() {
337 + loadingConfiguredSources.value = true
338 +
339 + Api.incidentManagement.sources
340 + .getConfiguredSources()
341 + .then(res => {
342 + if (res.data.success) {
343 + configuredSourcesList.value = res.data?.sources || []
344 + } else {
345 + message.warning(res.data?.message || "An error occurred. Please try again later.")
346 + }
347 + })
348 + .catch(err => {
349 + message.error(getApiErrorMessage(err as ApiError) || "An error occurred. Please try again later.")
350 + })
351 + .finally(() => {
352 + loadingConfiguredSources.value = false
353 + })
354 +}
355 +
356 +// Re-fetch when scope filters change so the result set follows the
357 +// backend's customer+source filtering semantics.
358 +watch([customerFilter, sourceFilter, includeGlobal], () => {
359 + fetchTemplates()
360 +})
361 +
362 +onBeforeMount(() => {
363 + fetchTemplates()
364 + getCustomers()
365 + getConfiguredSources()
366 +})
367 +</script>
frontend/src/components/incidentManagement/cases/CaseCreationForm.vue
+94 -3
@@ -52,6 +52,25 @@
52 />
53 </n-form-item>
54 </div>
55 + <div>
56 + <n-form-item label="Template (optional)" path="template_id">
57 + <n-select
58 + v-model:value="selectedTemplateId"
59 + :options="templateOptions"
60 + :loading="loadingTemplates"
61 + placeholder="Apply a template on creation (optional)"
62 + clearable
63 + filterable
64 + to="body"
65 + :render-label="renderOption"
66 + size="large"
67 + />
68 + </n-form-item>
69 + <p class="text-secondary -mt-2 text-xs">
70 + Applies the template's predefined tasks to the case. Filtered by the selected customer; global
71 + templates are always shown.
72 + </p>
73 + </div>
74
75 <div class="flex justify-between gap-4">
76 <div class="flex gap-4">
@@ -74,10 +93,11 @@ import type { FormInst, FormRules, FormValidationError } from "naive-ui"
93 import type { Ref } from "vue"
94 import type { Customer } from "@/types/customers.d"
95 import type { Case, CasePayload, CaseStatus } from "@/types/incidentManagement/cases.d"
96 +import type { CaseTemplate } from "@/types/incidentManagement/caseTemplates.d"
97 import _get from "lodash/get"
98 import _trim from "lodash/trim"
99 import { NButton, NForm, NFormItem, NInput, NSelect, NSpin, useMessage } from "naive-ui"
80 -import { computed, inject, onBeforeMount, onMounted, ref, watch } from "vue"
100 +import { computed, h, inject, onBeforeMount, onMounted, ref, watch } from "vue"
101 import Api from "@/api"
102
103 const emit = defineEmits<{
@@ -93,8 +113,25 @@ const emit = defineEmits<{
113
114 const loadingAvailableUsers = ref(false)
115 const loadingCustomersList = ref(false)
116 +const loadingTemplates = ref(false)
117 const submitting = ref(false)
97 -const loading = computed(() => loadingAvailableUsers.value || loadingCustomersList.value || submitting.value)
118 +const loading = computed(
119 + () => loadingAvailableUsers.value || loadingCustomersList.value || loadingTemplates.value || submitting.value
120 +)
121 +// Template picker state. Lives outside form because it's a query param to the
122 +// API call, not a body field on CasePayload.
123 +const availableTemplates = ref<CaseTemplate[]>([])
124 +const selectedTemplateId = ref<number | null>(null)
125 +const templateOptions = computed(() =>
126 + availableTemplates.value.map(t => ({
127 + label: t.name,
128 + name: t.name,
129 + customer_code: t.customer_code,
130 + source: t.source,
131 + is_default: t.is_default,
132 + value: t.id
133 + }))
134 +)
135 const message = useMessage()
136 const form = ref<CasePayload>(getForm())
137 const formRef = ref<FormInst | null>(null)
@@ -191,11 +228,32 @@ function resetForm() {
228 form.value = getForm()
229 }
230
231 +function renderOption(option: {
232 + label: string
233 + value: number
234 + name?: string | null
235 + is_default?: boolean
236 + customer_code?: string | null
237 + source?: string | null
238 +}) {
239 + const title = `${option.name}${option.is_default ? " (default)" : ""}`
240 + const description = [option.customer_code, option.source].filter(Boolean).join(" — ")
241 + return h("div", { class: "flex flex-col gap-0.5 leading-none py-2" }, [
242 + h("span", title),
243 + h("span", { class: "text-secondary text-xs" }, description)
244 + ])
245 +}
246 +
247 function submit() {
248 submitting.value = true
249
250 + const params: Record<string, number> = {}
251 + if (selectedTemplateId.value != null) {
252 + params.template_id = selectedTemplateId.value
253 + }
254 +
255 Api.incidentManagement.cases
198 - .createCase(form.value)
256 + .createCase(form.value, params)
257 .then(res => {
258 if (res.data.success) {
259 message.success(res.data?.message || "Case created successfully")
@@ -213,6 +271,28 @@ function submit() {
271 })
272 }
273
274 +function getTemplates(customerCode?: string | null) {
275 + loadingTemplates.value = true
276 + Api.incidentManagement.caseTemplates
277 + .listTemplates({
278 + customerCode: customerCode || undefined,
279 + includeGlobal: true
280 + })
281 + .then(res => {
282 + if (res.data.success) {
283 + availableTemplates.value = res.data.templates
284 + }
285 + })
286 + .catch(err => {
287 + // Soft failure — template picker is optional. Log via console rather
288 + // than spamming a toast, since the user can still submit without one.
289 + console.warn("Failed to load templates for picker:", err)
290 + })
291 + .finally(() => {
292 + loadingTemplates.value = false
293 + })
294 +}
295 +
296 function getAvailableUsers() {
297 loadingAvailableUsers.value = true
298
@@ -260,9 +340,20 @@ function load() {
340 if (!customersList.value.length) {
341 getCustomers()
342 }
343 + getTemplates(form.value.customer_code)
344 reset()
345 }
346
347 +// Re-fetch templates when the customer changes so the picker only offers
348 +// applicable + global templates. Reset the selection if it no longer applies.
349 +watch(
350 + () => form.value.customer_code,
351 + newCustomer => {
352 + selectedTemplateId.value = null
353 + getTemplates(newCustomer)
354 + }
355 +)
356 +
357 watch(loading, val => {
358 emit("update:loading", val)
359 })
frontend/src/components/incidentManagement/cases/CaseDetails.vue
+25 -1
@@ -30,6 +30,20 @@
30 </template>
31 </div>
32 </n-tab-pane>
33 + <n-tab-pane name="Tasks" tab="Tasks" display-directive="show:lazy">
34 + <div class="p-7 pt-4">
35 + <CaseTasksList
36 + :case-id="caseEntity.id"
37 + :customer-code="caseEntity.customer_code"
38 + :can-edit="canEditTasks"
39 + />
40 + </div>
41 + </n-tab-pane>
42 + <n-tab-pane name="Timeline" tab="Timeline" display-directive="show:lazy">
43 + <div class="p-7 pt-4">
44 + <CaseTimelineFeed :case-id="caseEntity.id" />
45 + </div>
46 + </n-tab-pane>
47 <n-tab-pane name="Comments" tab="Comments" display-directive="show:lazy">
48 <div class="p-7 pt-4">
49 <CaseCommentsList
@@ -52,8 +66,10 @@
66 import type { Case, CaseComment } from "@/types/incidentManagement/cases.d"
67 import _clone from "lodash/cloneDeep"
68 import { NEmpty, NSpin, NTabPane, NTabs, useMessage } from "naive-ui"
55 -import { defineAsyncComponent, onBeforeMount, ref, toRefs } from "vue"
69 +import { computed, defineAsyncComponent, onBeforeMount, ref, toRefs } from "vue"
70 import Api from "@/api"
71 +import { useAuthStore } from "@/stores/auth"
72 +import { AuthUserRole } from "@/types/auth.d"
73
74 const props = defineProps<{
75 caseData?: Case
@@ -66,8 +82,16 @@ const emit = defineEmits<{
82 const CaseOverview = defineAsyncComponent(() => import("./CaseOverview.vue"))
83 const CaseDataStore = defineAsyncComponent(() => import("./CaseDataStore.vue"))
84 const CaseCommentsList = defineAsyncComponent(() => import("./CaseCommentsList.vue"))
85 +const CaseTasksList = defineAsyncComponent(() => import("./CaseTasks/CaseTasksList.vue"))
86 +const CaseTimelineFeed = defineAsyncComponent(() => import("./CaseTimelineFeed.vue"))
87 const AlertItem = defineAsyncComponent(() => import("../alerts/AlertItem.vue"))
88
89 +// Customer-portal users see Tasks + Timeline read-only. The /frontend app is
90 +// primarily analyst-facing, but role-conditional rendering keeps it correct
91 +// if a customer_user happens to land on this view.
92 +const authStore = useAuthStore()
93 +const canEditTasks = computed(() => authStore.userRole !== AuthUserRole.CustomerUser)
94 +
95 const { caseData, caseId } = toRefs(props)
96
97 const message = useMessage()
frontend/src/components/incidentManagement/cases/CaseStatusSwitch.vue
+106 -29
@@ -3,20 +3,55 @@
3 v-model:value="statusSelected"
4 v-model:show="listVisible"
5 :options="statusOptions"
6 - :disabled="loading"
6 + :loading
7 size="medium"
8 scrollable
9 to="body"
10 >
11 <slot :loading />
12 </n-popselect>
13 +
14 + <!-- Soft-warning modal (issue #792 Phase 3 backend / Phase 5 UI). Fires
15 + when closing a case with mandatory tasks not marked DONE. Cancel
16 + reverts the dropdown; "Close anyway" re-submits with force=true. -->
17 + <n-modal
18 + v-model:show="showWarning"
19 + preset="card"
20 + title="Mandatory tasks incomplete"
21 + style="max-width: 560px"
22 + display-directive="show"
23 + >
24 + <p>
25 + This case has {{ pendingTasks.length }} mandatory task{{ pendingTasks.length === 1 ? "" : "s" }} that
26 + {{ pendingTasks.length === 1 ? "is" : "are" }} not marked
27 + <strong>Done</strong>
28 + . Closing anyway will record the override in the case timeline.
29 + </p>
30 +
31 + <ul class="text-sm">
32 + <li v-for="t in pendingTasks" :key="t.id" class="mb-1">
33 + <span class="font-medium">{{ t.title }}</span>
34 + <span class="text-tertiary">— {{ humanStatus(t.status) }}</span>
35 + </li>
36 + </ul>
37 +
38 + <template #footer>
39 + <div class="flex justify-end gap-2">
40 + <n-button @click="cancelClose">Cancel</n-button>
41 + <n-button type="warning" :loading @click="confirmForceClose">Close anyway</n-button>
42 + </div>
43 + </template>
44 + </n-modal>
45 </template>
46
47 <script setup lang="ts">
48 +import type { ApiError } from "@/types/common"
49 import type { Case, CaseStatus } from "@/types/incidentManagement/cases.d"
17 -import { NPopselect, useMessage } from "naive-ui"
50 +import type { CaseTask, CaseTaskStatus } from "@/types/incidentManagement/caseTemplates.d"
51 +import { NButton, NModal, NPopselect, useMessage } from "naive-ui"
52 import { computed, onBeforeMount, ref, toRefs, watch } from "vue"
53 import Api from "@/api"
54 +import { getApiErrorMessage } from "@/utils"
55
56 const props = defineProps<{
57 caseData: Case
@@ -31,42 +66,84 @@ const loading = ref(false)
66 const message = useMessage()
67 const listVisible = ref(false)
68 const status = computed(() => caseData.value.case_status)
34 -const statusOptions = ref<
35 - {
36 - label: string
37 - value: CaseStatus
38 - }[]
39 ->([
69 +const statusOptions = ref<{ label: string; value: CaseStatus }[]>([
70 { label: "Open", value: "OPEN" },
71 { label: "In progress", value: "IN_PROGRESS" },
72 { label: "Closed", value: "CLOSED" }
73 ])
74 const statusSelected = ref<CaseStatus | null>(null)
75
46 -function updateStatus() {
47 - if (statusSelected.value && statusSelected.value !== status.value) {
48 - loading.value = true
49 -
50 - Api.incidentManagement.cases
51 - .updateCaseStatus(caseData.value.id, statusSelected.value)
52 - .then(res => {
53 - if (res.data.success && statusSelected.value) {
54 - emit("updated", { ...caseData.value, case_status: statusSelected.value })
55 - } else {
56 - message.warning(res.data?.message || "An error occurred. Please try again later.")
57 - }
58 - })
59 - .catch(err => {
60 - message.error(err.response?.data?.message || "An error occurred. Please try again later.")
61 - })
62 - .finally(() => {
63 - loading.value = false
64 - })
76 +// Soft-warning state
77 +const showWarning = ref(false)
78 +const pendingTasks = ref<CaseTask[]>([])
79 +// Snapshot the status the dropdown was switching to so we can resubmit with
80 +// force=true after the user confirms. Separate from statusSelected because
81 +// canceling needs to revert the dropdown without retriggering this watcher.
82 +const pendingTargetStatus = ref<CaseStatus | null>(null)
83 +
84 +function humanStatus(s: CaseTaskStatus): string {
85 + return s === "TODO" ? "to do" : s === "DONE" ? "done" : "not necessary"
86 +}
87 +
88 +async function callUpdate(target: CaseStatus, force = false) {
89 + loading.value = true
90 + try {
91 + const res = await Api.incidentManagement.cases.updateCaseStatus(caseData.value.id, target, force)
92 + const data: any = res.data
93 +
94 + // Soft-warning shape from backend: success=false, requires_confirmation=true,
95 + // incomplete_mandatory_tasks=[]. Treat as a confirmation flow rather than an error.
96 + if (data && data.requires_confirmation === true) {
97 + pendingTasks.value = data.incomplete_mandatory_tasks ?? []
98 + pendingTargetStatus.value = target
99 + showWarning.value = true
100 + return
101 + }
102 +
103 + if (data?.success) {
104 + emit("updated", { ...caseData.value, case_status: target })
105 + } else {
106 + message.warning(data?.message || "An error occurred. Please try again later.")
107 + // Revert dropdown on plain failure.
108 + revertDropdown()
109 + }
110 + } catch (err) {
111 + message.error(getApiErrorMessage(err as ApiError) || "An error occurred. Please try again later.")
112 + revertDropdown()
113 + } finally {
114 + loading.value = false
115 }
116 }
117
68 -watch(statusSelected, () => {
69 - updateStatus()
118 +function revertDropdown() {
119 + if (status.value && statusSelected.value !== status.value) {
120 + statusSelected.value = status.value
121 + }
122 +}
123 +
124 +function cancelClose() {
125 + showWarning.value = false
126 + pendingTargetStatus.value = null
127 + pendingTasks.value = []
128 + revertDropdown()
129 +}
130 +
131 +async function confirmForceClose() {
132 + if (pendingTargetStatus.value == null) {
133 + showWarning.value = false
134 + return
135 + }
136 + const target = pendingTargetStatus.value
137 + showWarning.value = false
138 + await callUpdate(target, true)
139 + pendingTargetStatus.value = null
140 + pendingTasks.value = []
141 +}
142 +
143 +watch(statusSelected, newVal => {
144 + if (newVal && newVal !== status.value) {
145 + callUpdate(newVal, false)
146 + }
147 })
148
149 onBeforeMount(() => {
frontend/src/components/incidentManagement/cases/CaseTasks/CaseTaskItem.vue new
+281
@@ -0,0 +1,281 @@
1 +<template>
2 + <CardEntity
3 + :status="taskData?.status === 'DONE' ? 'success' : taskData?.status === 'NOT_NECESSARY' ? 'warning' : undefined"
4 + embedded
5 + >
6 + <template #headerMain>
7 + <div class="flex flex-wrap items-center gap-3">
8 + <span class="text-default font-sans text-base">
9 + {{ taskData?.title }}
10 + </span>
11 +
12 + <n-tag v-if="taskData?.mandatory" :bordered="false" type="error" size="small">mandatory</n-tag>
13 + <n-tag v-if="taskData?.template_task_id == null" :bordered="false" type="default" size="small">
14 + custom
15 + </n-tag>
16 + </div>
17 + </template>
18 + <template v-if="taskData" #headerExtra>
19 + <n-select
20 + v-if="canEdit"
21 + v-model:value="taskData.status"
22 + :options="statusOptions"
23 + :status="
24 + taskData.status === 'DONE' ? 'success' : taskData.status === 'NOT_NECESSARY' ? 'warning' : undefined
25 + "
26 + size="small"
27 + class="w-38!"
28 + :consistent-menu-width="false"
29 + :loading="savingStatus"
30 + />
31 + <n-tag v-else :bordered="false" :type="statusTagType(taskData.status)" size="small">
32 + {{ statusLabel(taskData.status) }}
33 + </n-tag>
34 + </template>
35 + <template #default>
36 + <div class="flex flex-col gap-3">
37 + <p v-if="taskData?.description" class="text-secondary text-sm">{{ taskData.description }}</p>
38 +
39 + <details v-if="taskData?.guidelines" class="text-sm">
40 + <summary class="cursor-pointer font-medium">Guidelines</summary>
41 + <p class="text-secondary mt-1 whitespace-pre-line">{{ taskData.guidelines }}</p>
42 + </details>
43 + </div>
44 + </template>
45 + <template v-if="taskData" #mainExtra>
46 + <div class="flex flex-col gap-1">
47 + <div class="flex items-center justify-between">
48 + <div class="text-secondary text-xs uppercase">Evidence / notes</div>
49 +
50 + <div
51 + class="text-secondary text-right text-xs opacity-0 transition-opacity duration-300"
52 + :class="{ 'animate-pulse opacity-100': savingEvidenceComment }"
53 + >
54 + saving...
55 + </div>
56 + </div>
57 + <div v-if="canEdit" class="flex flex-col gap-1">
58 + <n-input
59 + v-model:value="taskData.evidence_comment"
60 + type="textarea"
61 + clearable
62 + placeholder="Logs, command output, links — what proves this was done?"
63 + :autosize="{ minRows: 2, maxRows: 8 }"
64 + />
65 + </div>
66 + <p v-else-if="taskData.evidence_comment" class="text-sm whitespace-pre-line">
67 + {{ taskData.evidence_comment }}
68 + </p>
69 + <p v-else class="text-tertiary text-sm italic">No notes recorded</p>
70 + </div>
71 + </template>
72 + <template v-if="taskData" #footer>
73 + <div class="flex flex-wrap items-center justify-between gap-2">
74 + <div class="text-secondary flex flex-wrap gap-x-4 gap-y-1 text-sm">
75 + <span v-if="taskData.completed_by">
76 + {{ task.status === "DONE" ? "Completed" : "Marked" }} by
77 + <strong>{{ taskData.completed_by }}</strong>
78 + <template v-if="taskData.completed_at">
79 + · {{ formatDate(taskData.completed_at, dFormats.datetime) }}
80 + </template>
81 + </span>
82 + <span v-else>
83 + Created by
84 + <strong>{{ task.created_by }}</strong>
85 + </span>
86 + </div>
87 +
88 + <div>
89 + <div class="flex items-center justify-end gap-2">
90 + <n-button
91 + v-if="canEdit && taskData.template_task_id == null"
92 + size="tiny"
93 + quaternary
94 + type="error"
95 + :loading="deleting"
96 + @click="confirmDelete(taskData)"
97 + >
98 + <template #icon>
99 + <Icon :name="DeleteIcon" />
100 + </template>
101 + Delete
102 + </n-button>
103 + </div>
104 + </div>
105 + </div>
106 + </template>
107 + </CardEntity>
108 +</template>
109 +
110 +<script setup lang="ts">
111 +import type { ApiError } from "@/types/common"
112 +import type { CaseTask, CaseTaskStatus } from "@/types/incidentManagement/caseTemplates.d"
113 +import { useDebounceFn } from "@vueuse/core"
114 +import axios from "axios"
115 +import { NButton, NInput, NSelect, NTag, useDialog, useMessage } from "naive-ui"
116 +import { computed, ref, watch } from "vue"
117 +import Api from "@/api"
118 +import CardEntity from "@/components/common/cards/CardEntity.vue"
119 +import Icon from "@/components/common/Icon.vue"
120 +import { useSettingsStore } from "@/stores/settings"
121 +import { getApiErrorMessage } from "@/utils"
122 +import { formatDate } from "@/utils/format"
123 +
124 +const props = defineProps<{
125 + task: CaseTask
126 + caseId: number
127 + canEdit: boolean
128 +}>()
129 +
130 +const emit = defineEmits<{
131 + (e: "updated", value: CaseTask): void
132 + (e: "deleted"): void
133 +}>()
134 +
135 +const DeleteIcon = "carbon:trash-can"
136 +
137 +const message = useMessage()
138 +const dialog = useDialog()
139 +const dFormats = useSettingsStore().dateFormat
140 +const taskData = ref<CaseTask | null>(props.task)
141 +
142 +const savingStatus = ref(false)
143 +const savingEvidenceComment = ref(false)
144 +const deleting = ref(false)
145 +
146 +const statusOptions = computed(() => {
147 + const opts: { label: string; value: CaseTaskStatus; disabled?: boolean }[] = [
148 + { label: "To do", value: "TODO" },
149 + { label: "Done", value: "DONE" },
150 + { label: "Not necessary", value: "NOT_NECESSARY", disabled: taskData.value?.mandatory }
151 + ]
152 + return opts
153 +})
154 +
155 +let statusAbortController = new AbortController()
156 +let evidenceCommentAbortController = new AbortController()
157 +
158 +function statusLabel(status: CaseTaskStatus): string {
159 + return status === "TODO" ? "To do" : status === "DONE" ? "Done" : "Not necessary"
160 +}
161 +
162 +function statusTagType(status: CaseTaskStatus) {
163 + return status === "DONE" ? "success" : status === "NOT_NECESSARY" ? "warning" : "default"
164 +}
165 +
166 +const onStatusChange = useDebounceFn((newStatus: CaseTaskStatus) => {
167 + if (!taskData.value) return
168 +
169 + if (statusAbortController) {
170 + statusAbortController.abort()
171 + }
172 +
173 + statusAbortController = new AbortController()
174 + savingStatus.value = true
175 +
176 + Api.incidentManagement.caseTemplates
177 + .updateCaseTask(taskData.value.id, { status: newStatus }, statusAbortController.signal)
178 + .then(res => {
179 + if (res.data.success && res.data.task) {
180 + emit("updated", res.data.task)
181 + } else {
182 + message.warning(res.data.message || "Status update rejected")
183 + }
184 + savingStatus.value = false
185 + })
186 + .catch(err => {
187 + if (!axios.isCancel(err)) {
188 + savingStatus.value = false
189 + message.error(getApiErrorMessage(err as ApiError) || "Failed to update task status")
190 + }
191 + })
192 +}, 250)
193 +
194 +const onCommentChange = useDebounceFn((value: string | null) => {
195 + if (!taskData.value) return
196 +
197 + if (evidenceCommentAbortController) {
198 + evidenceCommentAbortController.abort()
199 + }
200 +
201 + evidenceCommentAbortController = new AbortController()
202 + savingEvidenceComment.value = true
203 +
204 + Api.incidentManagement.caseTemplates
205 + .updateCaseTask(taskData.value.id, { evidence_comment: value || "" }, evidenceCommentAbortController.signal)
206 + .then(res => {
207 + if (res.data.success && res.data.task) {
208 + emit("updated", res.data.task)
209 + } else {
210 + message.warning(res.data.message)
211 + }
212 + savingEvidenceComment.value = false
213 + })
214 + .catch(err => {
215 + if (!axios.isCancel(err)) {
216 + savingEvidenceComment.value = false
217 + message.error(getApiErrorMessage(err as ApiError) || "Failed to save evidence comment")
218 + }
219 + })
220 +}, 500)
221 +
222 +// Custom-task delete (analysts can only delete custom tasks via the chip — keeps
223 +// template-derived audit trails intact unless an admin really wants to nuke it).
224 +function confirmDelete(task: CaseTask) {
225 + dialog.warning({
226 + title: "Delete task?",
227 + content: `"${task.title}" will be removed from this case.`,
228 + positiveText: "Delete",
229 + negativeText: "Cancel",
230 + onPositiveClick: () => {
231 + deleting.value = true
232 +
233 + Api.incidentManagement.caseTemplates
234 + .deleteCaseTask(task.id)
235 + .then(res => {
236 + if (res.data.success) {
237 + emit("deleted")
238 + } else {
239 + message.warning(res.data.message)
240 + }
241 + })
242 + .catch(err => {
243 + message.error(getApiErrorMessage(err as ApiError) || "Failed to delete task")
244 + })
245 + .finally(() => {
246 + deleting.value = false
247 + })
248 + }
249 + })
250 +}
251 +
252 +watch(
253 + () => taskData.value?.status,
254 + val => {
255 + if (val) {
256 + onStatusChange(val)
257 + }
258 + }
259 +)
260 +
261 +watch(
262 + () => taskData.value?.evidence_comment,
263 + val => {
264 + onCommentChange(val || null)
265 + }
266 +)
267 +</script>
268 +
269 +<style scoped lang="scss">
270 +.task-card {
271 + transition: background-color 0.15s ease;
272 +
273 + &--done {
274 + background-color: rgba(0, 200, 80, 0.05);
275 + }
276 +
277 + &--skipped {
278 + background-color: rgba(160, 160, 160, 0.05);
279 + }
280 +}
281 +</style>
frontend/src/components/incidentManagement/cases/CaseTasks/CaseTasksCreateForm.vue new
+94
@@ -0,0 +1,94 @@
1 +<template>
2 + <n-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-placement="top" :disabled="submitting">
3 + <n-form-item label="Title" path="title">
4 + <n-input v-model:value="addForm.title" placeholder="What needs to be done?" />
5 + </n-form-item>
6 + <n-form-item path="mandatory" :show-label="false">
7 + <n-checkbox v-model:checked="addForm.mandatory">Mandatory (blocks close-with-warning)</n-checkbox>
8 + </n-form-item>
9 + <n-form-item label="Description (optional)" path="description">
10 + <n-input v-model:value="addForm.description" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" />
11 + </n-form-item>
12 + <n-form-item label="Guidelines (optional)" path="guidelines">
13 + <n-input
14 + v-model:value="addForm.guidelines"
15 + type="textarea"
16 + placeholder="Best practices / steps to follow"
17 + :autosize="{ minRows: 2, maxRows: 6 }"
18 + />
19 + </n-form-item>
20 + <div class="flex justify-end gap-2">
21 + <n-button type="primary" :loading="submitting" :disabled="!isValid" @click="submitAddTask">
22 + Add task
23 + </n-button>
24 + </div>
25 + </n-form>
26 +</template>
27 +
28 +<script setup lang="ts">
29 +import type { FormInst, FormRules } from "naive-ui"
30 +import type { ApiError } from "@/types/common"
31 +import { NButton, NCheckbox, NForm, NFormItem, NInput, useMessage } from "naive-ui"
32 +import { computed, ref } from "vue"
33 +import Api from "@/api"
34 +import { getApiErrorMessage } from "@/utils"
35 +
36 +const { caseId } = defineProps<{
37 + caseId: number
38 +}>()
39 +
40 +const emit = defineEmits<{
41 + (e: "success"): void
42 +}>()
43 +
44 +const message = useMessage()
45 +
46 +const submitting = ref(false)
47 +const addFormRef = ref<FormInst | null>(null)
48 +const addForm = ref({
49 + title: "",
50 + description: "",
51 + guidelines: "",
52 + mandatory: false
53 +})
54 +
55 +const addFormRules: FormRules = {
56 + title: { required: true, message: "Title is required", trigger: "blur" }
57 +}
58 +
59 +const isValid = computed(() => {
60 + return addForm.value.title.trim() !== ""
61 +})
62 +
63 +function resetForm() {
64 + addForm.value = { title: "", description: "", guidelines: "", mandatory: false }
65 +}
66 +
67 +async function submitAddTask() {
68 + try {
69 + await addFormRef.value?.validate()
70 + } catch {
71 + return
72 + }
73 + submitting.value = true
74 +
75 + try {
76 + const res = await Api.incidentManagement.caseTemplates.addCaseTask(caseId, {
77 + title: addForm.value.title,
78 + description: addForm.value.description || null,
79 + guidelines: addForm.value.guidelines || null,
80 + mandatory: addForm.value.mandatory
81 + })
82 + if (res.data.success && res.data.task) {
83 + resetForm()
84 + emit("success")
85 + } else {
86 + message.warning(res.data.message)
87 + }
88 + } catch (err) {
89 + message.error(getApiErrorMessage(err as ApiError) || "Failed to add task")
90 + } finally {
91 + submitting.value = false
92 + }
93 +}
94 +</script>
frontend/src/components/incidentManagement/cases/CaseTasks/CaseTasksList.vue new
+64
@@ -0,0 +1,64 @@
1 +<template>
2 + <div class="case-tasks-list flex flex-col gap-4">
3 + <CaseTasksToolbar :case-id :customer-code :can-edit :tasks @updated="fetchTasks" />
4 +
5 + <n-spin :show="loading">
6 + <div v-if="tasks.length" class="flex flex-col gap-3">
7 + <CaseTaskItem
8 + v-for="task in tasks"
9 + :key="task.id"
10 + :task
11 + :case-id
12 + :can-edit
13 + @deleted="fetchTasks"
14 + @updated="handleTaskUpdated"
15 + />
16 + </div>
17 + <n-empty v-else-if="!loading" description="No tasks on this case" class="h-32 justify-center" />
18 + </n-spin>
19 + </div>
20 +</template>
21 +
22 +<script setup lang="ts">
23 +import type { CaseTask } from "@/types/incidentManagement/caseTemplates.d"
24 +import { NEmpty, NSpin, useMessage } from "naive-ui"
25 +import { onBeforeMount, ref } from "vue"
26 +import Api from "@/api"
27 +import CaseTaskItem from "./CaseTaskItem.vue"
28 +import CaseTasksToolbar from "./CaseTasksToolbar.vue"
29 +
30 +const props = defineProps<{
31 + caseId: number
32 + customerCode?: string | null
33 + canEdit: boolean
34 +}>()
35 +
36 +const message = useMessage()
37 +const tasks = ref<CaseTask[]>([])
38 +const loading = ref(false)
39 +
40 +function fetchTasks() {
41 + loading.value = true
42 + Api.incidentManagement.caseTemplates
43 + .listCaseTasks(props.caseId)
44 + .then(res => {
45 + if (res.data.success) {
46 + tasks.value = res.data.tasks
47 + } else {
48 + message.warning(res.data.message)
49 + }
50 + })
51 + .catch(err => {
52 + message.error(err.response?.data?.message || "Failed to load case tasks")
53 + })
54 + .finally(() => {
55 + loading.value = false
56 + })
57 +}
58 +
59 +function handleTaskUpdated(task: CaseTask) {
60 + tasks.value = tasks.value.map(t => (t.id === task.id ? task : t))
61 +}
62 +
63 +onBeforeMount(fetchTasks)
64 +</script>
frontend/src/components/incidentManagement/cases/CaseTasks/CaseTasksToolbar.vue new
+109
@@ -0,0 +1,109 @@
1 +<template>
2 + <div>
3 + <!-- Header: counts + actions -->
4 + <div class="flex items-center justify-between gap-4">
5 + <div class="flex items-center gap-3 text-sm">
6 + <Badge type="splitted">
7 + <template #label>Tasks</template>
8 + <template #value>
9 + {{ tasks.length }}
10 + </template>
11 + </Badge>
12 + <Badge v-if="mandatoryIncomplete > 0" color="warning" type="splitted" bright>
13 + <template #label>Mandatory incomplete</template>
14 + <template #value>
15 + {{ mandatoryIncomplete }}
16 + </template>
17 + </Badge>
18 + <Badge v-if="totalDone > 0" color="success" type="splitted" bright>
19 + <template #label>Done</template>
20 + <template #value>{{ totalDone }}</template>
21 + </Badge>
22 + </div>
23 + <div v-if="canEdit" class="flex items-center gap-2">
24 + <n-button size="small" secondary @click="openApplyTemplate">
25 + <template #icon>
26 + <Icon :name="ApplyIcon" />
27 + </template>
28 + Apply template
29 + </n-button>
30 + <n-button size="small" type="primary" @click="openAddTask">
31 + <template #icon>
32 + <Icon :name="AddIcon" />
33 + </template>
34 + Add task
35 + </n-button>
36 + </div>
37 + </div>
38 +
39 + <!-- Add custom task modal -->
40 + <n-modal
41 + v-model:show="showAddModal"
42 + preset="card"
43 + display-directive="show"
44 + title="Add custom task"
45 + :style="{ maxWidth: 'min(600px, 90vw)', overflow: 'hidden' }"
46 + >
47 + <CaseTasksCreateForm :case-id @success="handleAddTaskSuccess" />
48 + </n-modal>
49 +
50 + <!-- Apply template modal -->
51 + <n-modal
52 + v-model:show="showApplyModal"
53 + preset="card"
54 + display-directive="show"
55 + title="Apply template"
56 + :style="{ maxWidth: 'min(600px, 90vw)', overflow: 'hidden' }"
57 + >
58 + <CaseTasksToolbarApplyTemplateForm :case-id :customer-code @success="handleApplyTemplateSuccess" />
59 + </n-modal>
60 + </div>
61 +</template>
62 +
63 +<script setup lang="ts">
64 +import type { CaseTask } from "@/types/incidentManagement/caseTemplates.d"
65 +import { NButton, NModal } from "naive-ui"
66 +import { computed, ref } from "vue"
67 +import Badge from "@/components/common/Badge.vue"
68 +import Icon from "@/components/common/Icon.vue"
69 +import CaseTasksCreateForm from "./CaseTasksCreateForm.vue"
70 +import CaseTasksToolbarApplyTemplateForm from "./CaseTasksToolbarApplyTemplateForm.vue"
71 +
72 +const props = defineProps<{
73 + caseId: number
74 + customerCode?: string | null
75 + canEdit: boolean
76 + tasks: CaseTask[]
77 +}>()
78 +
79 +const emit = defineEmits<{
80 + (e: "updated"): void
81 +}>()
82 +
83 +const AddIcon = "carbon:add"
84 +const ApplyIcon = "carbon:flow"
85 +
86 +const totalDone = computed(() => props.tasks.filter(t => t.status === "DONE").length)
87 +const mandatoryIncomplete = computed(() => props.tasks.filter(t => t.mandatory && t.status !== "DONE").length)
88 +
89 +const showAddModal = ref(false)
90 +const showApplyModal = ref(false)
91 +
92 +function openAddTask() {
93 + showAddModal.value = true
94 +}
95 +
96 +function openApplyTemplate() {
97 + showApplyModal.value = true
98 +}
99 +
100 +function handleAddTaskSuccess() {
101 + emit("updated")
102 + showAddModal.value = false
103 +}
104 +
105 +function handleApplyTemplateSuccess() {
106 + emit("updated")
107 + showApplyModal.value = false
108 +}
109 +</script>
frontend/src/components/incidentManagement/cases/CaseTasks/CaseTasksToolbarApplyTemplateForm.vue new
+133
@@ -0,0 +1,133 @@
1 +<template>
2 + <n-form label-placement="top">
3 + <n-form-item label="Template">
4 + <n-select
5 + v-model:value="selectedTemplateId"
6 + :options="templateOptions"
7 + placeholder="Pick a template to apply"
8 + :loading="loadingTemplates"
9 + :render-label="renderOption"
10 + to="body"
11 + size="large"
12 + filterable
13 + />
14 + </n-form-item>
15 + <p class="text-xs">
16 + Adds the template's tasks to this case. Existing tasks are preserved — you can layer multiple templates over
17 + a single investigation.
18 + </p>
19 + <div class="mt-6 flex justify-end gap-2">
20 + <n-button
21 + type="primary"
22 + :loading="applySubmitting"
23 + :disabled="selectedTemplateId === null"
24 + @click="submitApplyTemplate"
25 + >
26 + Apply
27 + </n-button>
28 + </div>
29 + </n-form>
30 +</template>
31 +
32 +<script setup lang="ts">
33 +import type { ApiError } from "@/types/common"
34 +import type { CaseTemplate } from "@/types/incidentManagement/caseTemplates.d"
35 +import { NButton, NForm, NFormItem, NSelect, useMessage } from "naive-ui"
36 +import { computed, h, onBeforeMount, ref } from "vue"
37 +import Api from "@/api"
38 +import { getApiErrorMessage } from "@/utils"
39 +
40 +const props = defineProps<{
41 + caseId: number
42 + customerCode?: string | null
43 +}>()
44 +
45 +const emit = defineEmits<{
46 + (e: "success"): void
47 +}>()
48 +
49 +const message = useMessage()
50 +
51 +const applySubmitting = ref(false)
52 +const loadingTemplates = ref(false)
53 +const availableTemplates = ref<CaseTemplate[]>([])
54 +const selectedTemplateId = ref<number | null>(null)
55 +
56 +const templateOptions = computed(() =>
57 + availableTemplates.value.map(t => ({
58 + label: t.name,
59 + name: t.name,
60 + customer_code: t.customer_code,
61 + source: t.source,
62 + is_default: t.is_default,
63 + value: t.id
64 + }))
65 +)
66 +
67 +function renderOption(option: {
68 + label: string
69 + value: number
70 + name?: string | null
71 + is_default?: boolean
72 + customer_code?: string | null
73 + source?: string | null
74 +}) {
75 + const title = `${option.name}${option.is_default ? " (default)" : ""}`
76 + const description = [option.customer_code, option.source].filter(Boolean).join(" — ")
77 + return h("div", { class: "flex flex-col gap-0.5 leading-none py-2" }, [
78 + h("span", title),
79 + h("span", { class: "text-secondary text-xs" }, description)
80 + ])
81 +}
82 +
83 +function openApplyTemplate() {
84 + selectedTemplateId.value = null
85 + loadingTemplates.value = true
86 +
87 + Api.incidentManagement.caseTemplates
88 + .listTemplates({
89 + customerCode: props.customerCode ?? undefined,
90 + includeGlobal: true
91 + })
92 + .then(res => {
93 + if (res.data.success) {
94 + availableTemplates.value = res.data.templates
95 + } else {
96 + message.warning(res.data.message)
97 + }
98 + })
99 + .catch(err => {
100 + message.error(getApiErrorMessage(err as ApiError) || "Failed to load templates")
101 + })
102 + .finally(() => {
103 + loadingTemplates.value = false
104 + })
105 +}
106 +
107 +async function submitApplyTemplate() {
108 + if (selectedTemplateId.value === null) return
109 +
110 + applySubmitting.value = true
111 +
112 + try {
113 + const res = await Api.incidentManagement.caseTemplates.applyTemplateToCase(
114 + props.caseId,
115 + selectedTemplateId.value
116 + )
117 + if (res.data.success) {
118 + message.success(`Applied template — ${res.data.tasks_added} task(s) added`)
119 + emit("success")
120 + } else {
121 + message.warning(res.data.message)
122 + }
123 + } catch (err) {
124 + message.error(getApiErrorMessage(err as ApiError) || "Failed to apply template")
125 + } finally {
126 + applySubmitting.value = false
127 + }
128 +}
129 +
130 +onBeforeMount(() => {
131 + openApplyTemplate()
132 +})
133 +</script>
frontend/src/components/incidentManagement/cases/CaseTimelineFeed.vue new
+197
@@ -0,0 +1,197 @@
1 +<template>
2 + <div class="case-timeline flex flex-col gap-6">
3 + <div class="flex items-center justify-between gap-3">
4 + <Badge type="splitted">
5 + <template #label>Events</template>
6 + <template #value>
7 + {{ events.length }}
8 + </template>
9 + </Badge>
10 +
11 + <n-button size="small" secondary @click="fetchTimeline">
12 + <template #icon><Icon name="carbon:renew" /></template>
13 + Refresh
14 + </n-button>
15 + </div>
16 +
17 + <n-spin :show="loading">
18 + <n-timeline v-if="events.length">
19 + <n-timeline-item
20 + v-for="event in events"
21 + :key="event.id"
22 + :type="timelineType(event)"
23 + :time="formatDate(event.timestamp, dFormats.datetimesec).toString()"
24 + >
25 + <template #header>
26 + <div class="flex flex-wrap items-center gap-2">
27 + <Icon :name="iconFor(event)" :size="16" class="text-secondary" />
28 + <span class="font-medium">{{ summary(event) }}</span>
29 + <n-tag size="tiny" :bordered="false">{{ event.actor }}</n-tag>
30 + </div>
31 + </template>
32 + <div v-if="hasDetail(event)" class="text-secondary mt-1 text-sm">
33 + <component :is="renderDetail(event)" />
34 + </div>
35 + </n-timeline-item>
36 + </n-timeline>
37 + <n-empty v-else-if="!loading" description="No timeline events yet" class="h-32 justify-center" />
38 + </n-spin>
39 + </div>
40 +</template>
41 +
42 +<script setup lang="ts">
43 +import type { CaseEvent } from "@/types/incidentManagement/caseTemplates.d"
44 +import { NButton, NEmpty, NSpin, NTag, NTimeline, NTimelineItem, useMessage } from "naive-ui"
45 +import { h, onBeforeMount, ref } from "vue"
46 +import Api from "@/api"
47 +import Badge from "@/components/common/Badge.vue"
48 +import Icon from "@/components/common/Icon.vue"
49 +import { useSettingsStore } from "@/stores/settings"
50 +import { formatDate } from "@/utils/format"
51 +
52 +const props = defineProps<{
53 + caseId: number
54 +}>()
55 +
56 +const dFormats = useSettingsStore().dateFormat
57 +
58 +const message = useMessage()
59 +const events = ref<CaseEvent[]>([])
60 +const loading = ref(false)
61 +
62 +function fetchTimeline() {
63 + loading.value = true
64 + Api.incidentManagement.caseTemplates
65 + .getCaseTimeline(props.caseId)
66 + .then(res => {
67 + if (res.data.success) {
68 + events.value = res.data.events
69 + } else {
70 + message.warning(res.data.message)
71 + }
72 + })
73 + .catch(err => {
74 + message.error(err.response?.data?.message || "Failed to load case timeline")
75 + })
76 + .finally(() => {
77 + loading.value = false
78 + })
79 +}
80 +
81 +// Per-event-type rendering. Falls back gracefully on unknown event types
82 +// so a future backend addition doesn't blank-render the timeline.
83 +function summary(event: CaseEvent): string {
84 + const p = (event.payload || {}) as Record<string, any>
85 + switch (event.event_type) {
86 + case "case_created":
87 + return p.source === "from_alert" ? `Case created from alert #${p.alert_id}` : "Case created"
88 + case "case_status_changed":
89 + return p.forced
90 + ? `Status forced from ${p.from ?? "—"} to ${p.to} (mandatory tasks bypassed)`
91 + : `Status changed from ${p.from ?? "—"} to ${p.to}`
92 + case "case_assigned":
93 + return p.from
94 + ? `Reassigned from ${p.from} to ${p.to ?? "unassigned"}`
95 + : `Assigned to ${p.to ?? "unassigned"}`
96 + case "case_escalated":
97 + return p.escalated ? "Case escalated" : "Case de-escalated"
98 + case "alert_linked":
99 + return p.alert_ids ? `${p.alert_ids.length} alert(s) linked to case` : `Alert #${p.alert_id} linked`
100 + case "alert_unlinked":
101 + return `Alert #${p.alert_id} unlinked`
102 + case "comment_added":
103 + return "Comment added"
104 + case "template_applied":
105 + return `Template applied: ${p.template_name ?? `#${p.template_id}`} (${p.tasks_added ?? 0} task${p.tasks_added === 1 ? "" : "s"})`
106 + case "task_added":
107 + return `Task added: ${p.title ?? `#${p.task_id}`}${p.mandatory ? " (mandatory)" : ""}`
108 + case "task_status_changed":
109 + return `Task ${p.title ?? `#${p.task_id}`}: ${p.from_status ?? "—"} → ${p.to_status ?? "—"}`
110 + case "task_commented":
111 + return `Evidence added on task: ${p.title ?? `#${p.task_id}`}`
112 + default:
113 + return String(event.event_type).replace(/_/g, " ")
114 + }
115 +}
116 +
117 +function timelineType(event: CaseEvent): "default" | "success" | "info" | "warning" | "error" {
118 + const p = (event.payload || {}) as Record<string, any>
119 + switch (event.event_type) {
120 + case "case_created":
121 + case "alert_linked":
122 + case "template_applied":
123 + return "info"
124 + case "case_status_changed":
125 + return p.to === "CLOSED" ? "success" : p.to === "OPEN" ? "info" : "warning"
126 + case "task_status_changed":
127 + return p.to_status === "DONE" ? "success" : p.to_status === "NOT_NECESSARY" ? "warning" : "default"
128 + case "case_escalated":
129 + return p.escalated ? "warning" : "default"
130 + case "alert_unlinked":
131 + return "warning"
132 + case "comment_added":
133 + case "task_added":
134 + case "task_commented":
135 + case "case_assigned":
136 + default:
137 + return "default"
138 + }
139 +}
140 +
141 +function iconFor(event: CaseEvent): string {
142 + switch (event.event_type) {
143 + case "case_created":
144 + return "carbon:document-add"
145 + case "case_status_changed":
146 + return "carbon:flow-modeler"
147 + case "case_assigned":
148 + return "carbon:user-avatar-filled-alt"
149 + case "case_escalated":
150 + return "carbon:warning-alt"
151 + case "alert_linked":
152 + return "carbon:link"
153 + case "alert_unlinked":
154 + return "carbon:unlink"
155 + case "comment_added":
156 + return "carbon:chat"
157 + case "template_applied":
158 + return "carbon:flow"
159 + case "task_added":
160 + return "carbon:add-alt"
161 + case "task_status_changed":
162 + return "carbon:checkmark"
163 + case "task_commented":
164 + return "carbon:notebook"
165 + default:
166 + return "carbon:circle-dash"
167 + }
168 +}
169 +
170 +function hasDetail(event: CaseEvent): boolean {
171 + const p = (event.payload || {}) as Record<string, any>
172 + return !!(p.snippet || (event.event_type === "alert_linked" && p.alert_ids))
173 +}
174 +
175 +function renderDetail(event: CaseEvent) {
176 + const p = (event.payload || {}) as Record<string, any>
177 + if (event.event_type === "comment_added" && p.snippet) {
178 + return () => h("blockquote", { class: "border-border mt-1 border-l-4 pl-3 italic" }, String(p.snippet))
179 + }
180 + if (event.event_type === "task_commented" && p.snippet) {
181 + return () => h("blockquote", { class: "border-border mt-1 border-l-4 pl-3 italic" }, String(p.snippet))
182 + }
183 + if (event.event_type === "alert_linked" && Array.isArray(p.alert_ids)) {
184 + return () =>
185 + h(
186 + "span",
187 + { class: "text-tertiary" },
188 + `Alerts: ${(p.alert_ids as number[]).map((n: number) => `#${n}`).join(", ")}`
189 + )
190 + }
191 + return () => h("span")
192 +}
193 +
194 +defineExpose({ refresh: fetchTimeline })
195 +
196 +onBeforeMount(fetchTimeline)
197 +</script>
frontend/src/components/snapshots/SnapshotScheduleForm.vue
+9 -21
@@ -55,15 +55,10 @@
55 <n-divider title-placement="left">Schedule Window</n-divider>
56
57 <n-form-item label="Day of Week" path="day_of_week">
58 - <n-select
59 - v-model:value="formData.day_of_week"
60 - :options="weekdayOptions"
61 - placeholder="Any day"
62 - clearable
63 - />
58 + <n-select v-model:value="formData.day_of_week" :options="weekdayOptions" placeholder="Any day" clearable />
59 <template #feedback>
65 - Restrict execution to a single day of the week. Leave empty to allow any day.
66 - Combines with Interval (Days) for patterns like "every other Sunday".
60 + Restrict execution to a single day of the week. Leave empty to allow any day. Combines with Interval
61 + (Days) for patterns like "every other Sunday".
62 </template>
63 </n-form-item>
64
@@ -77,8 +72,8 @@
72 style="width: 100%"
73 />
74 <template #feedback>
80 - Hour of day (0-23) when this schedule is allowed to run. Leave empty to allow any hour
81 - (legacy behavior — runs every poll).
75 + Hour of day (0-23) when this schedule is allowed to run. Leave empty to allow any hour (legacy behavior
76 + — runs every poll).
77 </template>
78 </n-form-item>
79
@@ -93,21 +88,14 @@
88 :disabled="formData.scheduled_hour == null"
89 />
90 <template #feedback>
96 - Minute of hour. The schedule runs within a 15-minute tolerance window starting at
97 - this minute. Requires Scheduled Hour to be set.
91 + Minute of hour. The schedule runs within a 15-minute tolerance window starting at this minute. Requires
92 + Scheduled Hour to be set.
93 </template>
94 </n-form-item>
95
96 <n-form-item label="Interval (Days)" path="interval_days">
102 - <n-input-number
103 - v-model:value="formData.interval_days"
104 - :min="1"
105 - :max="365"
106 - style="width: 100%"
107 - />
108 - <template #feedback>
109 - Minimum number of days between executions. Default 1 = at most once per day.
110 - </template>
97 + <n-input-number v-model:value="formData.interval_days" :min="1" :max="365" style="width: 100%" />
98 + <template #feedback>Minimum number of days between executions. Default 1 = at most once per day.</template>
99 </n-form-item>
100
101 <n-form-item label="Timezone" path="timezone">
frontend/src/router/index.ts
+11 -1
@@ -1,6 +1,6 @@
1 import type { FormType } from "@/components/auth/types.d"
2 import { createRouter, createWebHistory } from "vue-router"
3 -import { RouteRole } from "@/types/auth.d"
3 +import { AuthUserRole, RouteRole } from "@/types/auth.d"
4 import { Layout } from "@/types/theme.d"
5 import { authCheck } from "@/utils/auth"
6 import AuthPage from "@/views/Auth.vue"
@@ -267,6 +267,16 @@ const router = createRouter({
267 component: () => import("@/views/incidentManagement/Cases.vue"),
268 meta: { title: "Incident Cases" }
269 },
270 + {
271 + path: "case-templates",
272 + name: "IncidentManagement-CaseTemplates",
273 + component: () => import("@/views/incidentManagement/CaseTemplates.vue"),
274 + meta: {
275 + title: "Case Templates",
276 + // Admin/analyst only — templates are SOC-team-managed playbooks.
277 + roles: [AuthUserRole.Admin, AuthUserRole.Analyst]
278 + }
279 + },
280 {
281 path: "sigma",
282 name: "IncidentManagement-Sigma",
frontend/src/types/incidentManagement/caseTemplates.d.ts new
+132
@@ -0,0 +1,132 @@
1 +// Mirrors the backend Pydantic schemas in
2 +// backend/app/incidents/schema/case_templates.py.
3 +//
4 +// Naming convention: TypeScript camelCase prefix is reserved for
5 +// frontend-only types; over-the-wire payloads keep the snake_case
6 +// shape returned by FastAPI so axios doesn't have to remap fields.
7 +
8 +export type CaseTaskStatus = "TODO" | "DONE" | "NOT_NECESSARY"
9 +
10 +export type CaseEventType =
11 + | "case_created"
12 + | "case_status_changed"
13 + | "case_assigned"
14 + | "case_escalated"
15 + | "alert_linked"
16 + | "alert_unlinked"
17 + | "comment_added"
18 + | "template_applied"
19 + | "task_added"
20 + | "task_status_changed"
21 + | "task_commented"
22 +
23 +// ----- CaseTemplate (admin/analyst-managed) -----
24 +
25 +export interface CaseTemplateTask {
26 + id: number
27 + template_id: number
28 + title: string
29 + description?: string | null
30 + guidelines?: string | null
31 + mandatory: boolean
32 + order_index: number
33 +}
34 +
35 +export interface CaseTemplateTaskCreatePayload {
36 + title: string
37 + description?: string | null
38 + guidelines?: string | null
39 + mandatory?: boolean
40 + order_index?: number
41 +}
42 +
43 +export interface CaseTemplateTaskUpdatePayload {
44 + title?: string
45 + description?: string | null
46 + guidelines?: string | null
47 + mandatory?: boolean
48 + order_index?: number
49 +}
50 +
51 +export interface CaseTemplate {
52 + id: number
53 + name: string
54 + description?: string | null
55 + customer_code?: string | null
56 + source?: string | null
57 + is_default: boolean
58 + created_by: string
59 + created_at: string
60 + updated_at: string
61 + tasks: CaseTemplateTask[]
62 +}
63 +
64 +export interface CaseTemplateCreatePayload {
65 + name: string
66 + description?: string | null
67 + customer_code?: string | null
68 + source?: string | null
69 + is_default?: boolean
70 + tasks?: CaseTemplateTaskCreatePayload[]
71 +}
72 +
73 +export interface CaseTemplateUpdatePayload {
74 + name?: string
75 + description?: string | null
76 + customer_code?: string | null
77 + source?: string | null
78 + is_default?: boolean
79 +}
80 +
81 +// ----- CaseTask (per-case instance) -----
82 +
83 +export interface CaseTask {
84 + id: number
85 + case_id: number
86 + template_task_id?: number | null
87 + title: string
88 + description?: string | null
89 + guidelines?: string | null
90 + mandatory: boolean
91 + order_index: number
92 + status: CaseTaskStatus
93 + evidence_comment?: string | null
94 + completed_by?: string | null
95 + completed_at?: string | null
96 + created_by: string
97 + created_at: string
98 + updated_at: string
99 +}
100 +
101 +export interface CaseTaskCreatePayload {
102 + title: string
103 + description?: string | null
104 + guidelines?: string | null
105 + mandatory?: boolean
106 + order_index?: number
107 +}
108 +
109 +export interface CaseTaskUpdatePayload {
110 + status?: CaseTaskStatus
111 + evidence_comment?: string | null
112 +}
113 +
114 +// ----- Soft-warning close response -----
115 +
116 +export interface CaseCloseWarningResponse {
117 + success: false
118 + requires_confirmation: true
119 + message: string
120 + incomplete_mandatory_tasks: CaseTask[]
121 +}
122 +
123 +// ----- Timeline -----
124 +
125 +export interface CaseEvent {
126 + id: number
127 + case_id: number
128 + event_type: CaseEventType
129 + actor: string
130 + timestamp: string
131 + payload?: Record<string, unknown> | null
132 +}
frontend/src/views/incidentManagement/CaseTemplates.vue new
+9
@@ -0,0 +1,9 @@
1 +<template>
2 + <div class="page">
3 + <CaseTemplatesList />
4 + </div>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import CaseTemplatesList from "@/components/incidentManagement/caseTemplates/CaseTemplatesList.vue"
9 +</script>
mkdocs.yml
+1
@@ -90,6 +90,7 @@ nav:
90 - Sources: user/ui/incident-sources.md
91 - Alerts: user/ui/incident-alerts.md
92 - Cases: user/ui/incident-cases.md
93 + - Case Templates: user/ui/incident-case-templates.md
94 - Alerting → Shuffle (notifications): user/ui/alerting-shuffle.md
95 - Alerts:
96 - Alerts: user/ui/alerts.md