Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

202 lines
11 KiB
Markdown

# PRD: Outbound Webhooks for Projects
- **Status:** Draft (loop-ready)
- **Owner:** Natallia Bukhtsik
- **Created:** 2026-05-15
- **Source draft:** `.ai/webhook-entity-metadata-registry-plan.md`
- **Branch:** `webhooks_expansion`
## 1. Problem statement & user value
Alga PSA delivers outbound webhooks for tickets but not for projects, even
though internal `PROJECT_*` / `PROJECT_TASK_*` events already fire on the event
bus. MSP integrators cannot react programmatically to project lifecycle changes
(creation, status, assignment, closure) or project-task changes. The outbound
webhook infrastructure (DB tables, vault-stored signing secrets, Redis-backed
`WebhookDeliveryQueue`, HMAC signing, retry, the `AdminWebhooksSetup` UI,
per-subscriber field allowlists) is **already entity-agnostic** — only the
ticket-specific glue is hardwired. This effort adds the project-specific glue so
subscribers can register project webhooks with the same field-allowlist control
they have for tickets.
## 2. Goals
- Deliver outbound webhooks for 5 project-level events and 5 project-task
events through the existing queue/signing/retry pipeline.
- Per-subscriber field allowlist support for the new `project` entity, reusing
the Phase 0 single-source `payloadFields.ts` registry and the existing
projection helper.
- Opt-in sub-entity payload sections (`phases`, `task_counts`) mirroring the
proven ticket `comments` batched-fetch pattern.
- Backward-compatible public webhook event enum (no breakage for existing
`webhook_subscriptions` rows referencing `project.completed`).
## 3. Non-goals
- No webhook queue, schema, signing, or UI redesign (infra is reused as-is).
- No new operational tooling (monitoring/metrics/dashboards) — out of scope.
- No project-level tags in the payload (the tag system has no `project`
tagged_type — see §7).
- `PROJECT_TASK_UPDATED` covers form-edit field changes and interactive tag
changes only; status/phase-move/reorder/dependency mutations are explicitly
out of scope (see §7).
- No generic `dispatchEntityWebhooks` abstraction yet (deferred follow-up).
## 4. Personas & primary flows
- **MSP integrator / admin:** In Settings → Security → Webhooks, registers a
webhook for e.g. `project.created` + `project.status_changed`, selects a
subset of payload fields (optionally `phases`), and receives signed HTTP
deliveries when projects change.
- **External receiving system:** Gets the same envelope/signature contract as
ticket webhooks; payload is projected to the subscriber's allowlist with
`project_id` (and `task_id` for task events) always retained.
## 5. Scope: events
**Project-level** (entity `project`): `project.created`, `project.updated`,
`project.status_changed`, `project.assigned`, `project.closed`.
**Project-task** (entity `project`, routed via single-entity decision §6):
`project.task.created`, `project.task.updated`, `project.task.status_changed`,
`project.task.assigned`, `project.task.completed`.
Internal sources (verified present):
- `projectActions.ts` already emits `PROJECT_CREATED`, `PROJECT_UPDATED`,
`PROJECT_STATUS_CHANGED`, `PROJECT_ASSIGNED`, `PROJECT_CLOSED`.
- `projectTaskActions.ts` already emits `PROJECT_TASK_CREATED`,
`PROJECT_TASK_STATUS_CHANGED`, `PROJECT_TASK_ASSIGNED`,
`PROJECT_TASK_COMPLETED`. `PROJECT_TASK_UPDATED` does **not** exist and is
added by this work.
**Tag-driven updates (in scope — projects + tickets, parity).** Adding/removing
a tag on a project task or a ticket currently fires no per-entity event (tag
actions emit only `TAG_DEFINITION_UPDATED`), so it produces no webhook today.
This work adds emission of `PROJECT_TASK_UPDATED` / `TICKET_UPDATED` (with
`changes: { tags: {...} }`) from the interactive tag mutation path. These
surface as the existing `project.task.updated` / `ticket.updated` deliveries —
no new public event types. The ticket side is included for parity (same gap
exists for tickets today).
## 6. Resolved design decisions
1. **Single `project` allowlist entity** (user-confirmed 2026-05-15). Both
project-level and task-level events route to entity `project`;
`webhookEntityForEventType()` is unchanged (first-dot slice). Trade-off
accepted: the field picker for a `project.created` webhook will list
task-only fields (`task_name`, `phase_id`, …) the event never carries.
Task events require `applyPayloadAllowlist('project', payload, allowlist,
extraAlwaysIncluded:['task_id'])` so `task_id` survives projection.
2. **Helper rename:** `projectWebhookPayload``applyPayloadAllowlist` (verb
collides with the new `project` noun domain). Done as an isolated mechanical
refactor before project code is added.
3. **`project.completed` is a deprecated accepted alias** of `project.closed`
(and `project.task.completed` retained likewise). The public enum keeps
them; the event map resolves them to closed/completed semantics. Existing
`webhook_subscriptions` rows are checked, not broken.
4. **Tag changes trigger webhooks for project tasks AND tickets**
(user-confirmed 2026-05-15). Emit `PROJECT_TASK_UPDATED` / `TICKET_UPDATED`
with `changes.tags` from the interactive tag path only. No new public event
types — reuses `*.updated`. Ticket parity is explicitly in scope.
## 7. Constraints & corrected facts (must hold)
- **Phase 0 is already implemented** (single-source `payloadFields.ts`
consolidation) and its tests pass. It is a *precondition*, verification-only,
not buildable work. It should land as its own commit before Phase 1.
- **Tag system:** `shared/models/tagModel.ts` `tagged_type` enum is
`['client','contact','project_task','document','knowledge_base_article']`.
`ProjectWebhookPayload` ships **without** `tags`.
`ProjectTaskWebhookPayload` uses `TagMapping.getByEntity(..., 'project_task')`.
- **`PROJECT_TASK_UPDATED` emit scope:** emitted from
`updateTaskWithChecklist` (form field edits) **and** the interactive tag
mutation path for `project_task` (F008, `changes.tags`). The other five
task-mutation entry points (`updateTaskStatus`, `moveTaskToPhase`,
`reorderTask`, `reorderTasksInStatus`, `updateTaskDependency`) intentionally
do **not** emit it; they have dedicated events or no webhook-relevant delta.
- **Tag emission must not double-fire (F008).** `createTagsForEntity` runs at
entity-creation time (`TaskForm.tsx:910`, immediately after
`PROJECT_TASK_CREATED`). Tag-change emission is scoped to *interactive*
single-entity mutations (`createTag` :101, `deleteTag` :335, `TagManager`
onChange) only — never the bulk `createTagsForEntity` :534 /
`createTagsForEntityWithTransaction` :571. No-op tag writes emit nothing.
- **Ticket parity (F008):** the ticket webhook builder already attaches
`changes` on `TICKET_UPDATED` (`webhookTicketPayload.ts:143`), so the ticket
side needs no builder change — only the event emission from `tagActions.ts`.
- **Strict type:** keep `ALWAYS_INCLUDED_KEYS_BY_ENTITY` typed
`as const satisfies Record<WebhookPayloadEntity, readonly string[]>` — do not
loosen to `Record<string, …>`.
- The projection helper now lives in `server/src/lib/webhooks/payloadFields.ts`
(Phase 0 moved it out of `webhookTicketPayload.ts`).
## 8. Data / API integration notes
- New internal event `PROJECT_TASK_UPDATED` in
`packages/event-schemas/src/schemas/eventBusSchema.ts`: payload
`{ tenantId, projectId, projectTaskId, phaseId, userId?, occurredAt?,
changes?: Record<string,{previous,new}> }`; add to event-type enum,
`EventSchemas` map, inferred type export.
- `ProjectWebhookPayload` (no `tags`): `project_id` (always), `project_name`,
`wbs_code`, `description`, `status_id`, `status_name`, `is_closed`,
`previous_status_id?`, `previous_status_name?`, `client_id`, `client_name`,
`contact_name_id`, `contact_name`, `contact_email`, `assigned_to`,
`assigned_to_name`, `start_date`, `end_date`, `budgeted_hours`,
`url=${NEXTAUTH_URL}/msp/projects/${project_id}`, `changes?`, `phases?`,
`task_counts?`.
- `ProjectTaskWebhookPayload`: project context (`project_id`, `project_name`,
`client_id`, `client_name`) + `task_id`, `phase_id`, `phase_name`,
`task_name`, `description`, `status_id`, `status_name`, `is_closed`,
`previous_status_id?`, `previous_status_name?`, `assigned_to`,
`assigned_to_name`, `estimated_hours`, `actual_hours`, `due_date`,
`priority_id`, `priority_name`, `wbs_code`,
`url=${NEXTAUTH_URL}/msp/projects/${project_id}?taskId=${task_id}` (verify
route), `tags` (via `project_task`), `changes?`.
- Public OpenAPI schema auto-extends from `WEBHOOK_PAYLOAD_FIELDS_BY_ENTITY`
(Phase 0 generates the per-entity enum from this map — no manual doc edits).
## 9. Risks, rollout, open questions
- **Rollout:** additive; project webhooks go live only when
`projectWebhookSubscriber` is registered in `subscribers/index.ts`
(last feature). Public enum aliasing prevents subscription-row breakage.
- **Risk:** task-payload `url` route shape unverified — confirm
`/msp/projects/:id?taskId=:taskId` against the app router before shipping.
- **Open question (non-blocking):** product may later want project-level tags;
requires a tag-system change, tracked as a follow-up, not this plan.
- **Follow-up (deferred):** extract `dispatchEntityWebhooks(entity, event,
builder)` once ticket + project subscribers both exist (~80% shared body).
## 10. Acceptance criteria / definition of done
- All 10 project/task public events are registrable in the Webhooks UI and the
public API enum, with `project.completed` / `project.task.completed`
accepted as deprecated aliases (no existing-subscription breakage).
- Creating/updating/closing a project and creating/updating/completing a task
produces a signed HTTP delivery and a `webhook_deliveries` row, payload
correctly projected to the subscriber allowlist with `project_id`
(and `task_id` for task events) always present.
- `phases` / `task_counts` populate only when in the allowlist or full payload,
fetched once per event regardless of subscriber count.
- No project-level `tags`; task `tags` resolve via `project_task`.
- `applyPayloadAllowlist` rename complete, all callers/tests green.
- Adding/removing a tag on a project task delivers `project.task.updated`
with a `changes.tags` diff; on a ticket delivers `ticket.updated` likewise;
creating an entity with initial tags does not double-fire; no-op tag writes
emit nothing.
- Full webhook unit + integration suite green; workspace type-check clean.
## 11. Loop execution notes
- 8 coarse features; each is one self-contained commit. Execution order is
array order in `features.json`:
`F001 → F002 → F003 → F008 → F004 → F005 → F006 → F007`
(IDs are stable, not sequential — `F008` runs 4th, right after the
`PROJECT_TASK_UPDATED` event it depends on exists).
- `F001` is the already-done Phase 0 precondition (`implemented: true`,
verification + standalone commit only — the loop must not rebuild it).
- Ordering is dependency-safe: rename → event → tag-emission → registration →
builders → subscriber → tests. Pure-new-code features (`F005`) have no
callers until `F006` registers the subscriber.
- Per-repo policy, commits/pushes happen only on explicit user request even
inside the loop.