- Framework: Fastify 5.8.4 with TypeScript
- Database: PostgreSQL 15+ with TimescaleDB extension
- ORM/Query Builder: Knex 3.1.0
- Validation: Zod 3.23.8
- Testing: Vitest 2.1.5
- Node Version: 20+
- Service Layer: Class-based services with dependency injection via constructor
- Controller Layer: Fastify route handlers with schema validation
- Database Access: Direct Knex queries via
getDatabase()singleton - Transaction Pattern:
db.transaction(async (trx) => { ... })for multi-table writes - Error Handling: Services throw errors; controllers catch and return appropriate HTTP status codes
- Alert Identifier: UUID (
alert_rules.id) - Foreign Key Type: UUID with
gen_random_uuid()default - Owner Model: Currently
owner_address(string) inalert_rulestable - Naming Convention: snake_case for database columns, camelCase for TypeScript
- Table:
audit_logs(migration 013) - Structure: Generic audit log with
action,actor_id,actor_type,resource_type,resource_id,before,after,metadata,severity,checksum,created_at - Append-Only: No update or delete operations on audit logs
- Tamper Detection: SHA-256 checksum computed from entry fields
- Pattern: Will reuse this existing audit log table rather than creating a new ownership-specific audit table
- Formats: CSV and JSON (PDF exists but not commonly used)
- Library:
csv-stringifyfor CSV generation - Streaming: Supported via
JSONStreamfor large datasets - Response Headers:
Content-TypeandContent-Dispositionheaders set appropriately - Pattern: Direct streaming in route handlers for smaller datasets; async job queue for large exports
- Implementation: Database-level LIKE queries with ILIKE for case-insensitive search
- Pattern:
db.where('column', 'ilike',%${query}%)ordb.whereRaw("column ILIKE ?", [%${query}%]) - No Full-Text Search: No existing pg_trgm or ts_vector usage found
- Middleware:
authMiddleware()frombackend/src/api/middleware/auth.ts - API Key: Validated via
x-api-keyheader - Scopes: Optional
requiredScopesarray (e.g.,["admin:audit"]) - User Identity: Stored in
request.apiKeyAuthafter validation - Pattern: Apply middleware via
preHandlerhook orserver.addHook("preHandler", authMiddleware())
- Unit Tests: Vitest with mocked services
- Integration Tests: Vitest with real database (PostgreSQL test instance)
- Test Database:
bridge_watch_testdatabase in CI - Mocking:
vi.mock()for service dependencies - HTTP Testing:
server.inject()for route testing - Coverage: Vitest coverage via
--coverageflag
- Lint:
npm --workspace=backend run lint(ESLint, zero errors) - Build:
npm --workspace=backend run build(TypeScript compilation) - Migrations:
npm --workspace=backend run migrate(all migrations apply cleanly) - Tests:
npm --workspace=backend run test -- --coverage(all tests pass) - Coverage: Uploaded to Codecov (no minimum threshold enforced in CI, but 90% target for new code)
Based on reconnaissance, the ownership model will support:
- Single owner per alert: One user or team owns each alert (enforced by unique constraint on
alert_id) - Owner type: Enum distinguishing
uservsteamownership - Owner ID: String type (consistent with
owner_addresspattern in existingalert_rules)
Rationale: The existing alert_rules table uses owner_address (string), suggesting a wallet-address-based ownership model. The ownership matrix will follow this pattern.
Tables:
-
alert_ownershipid— UUID, primary key,gen_random_uuid()alert_id— UUID, foreign key toalert_rules.id, unique constraintowner_type— ENUM (user,team)owner_id— VARCHAR(255), the user wallet address or team identifiercreated_at— TIMESTAMP,knex.fn.now()created_by— VARCHAR(255), actor who assigned ownership- Index:
(alert_id),(owner_id),(owner_type, owner_id)
-
escalation_contactsid— UUID, primary key,gen_random_uuid()alert_id— UUID, foreign key toalert_rules.idcontact_user_id— VARCHAR(255), user identifier for escalationorder— INTEGER, escalation sequence (1, 2, 3, ...)created_at— TIMESTAMP,knex.fn.now()created_by— VARCHAR(255), actor who added contact- Unique constraint:
(alert_id, contact_user_id) - Index:
(alert_id, order)
Audit Log: Will reuse existing audit_logs table with:
action:alert.ownership_assigned,alert.ownership_transferred,alert.escalation_added,alert.escalation_removedresource_type:alert_ownershiporescalation_contactresource_id:alert_idbefore/after: JSON snapshots of ownership stateactor_id: User performing the actionactor_type:userorapi_key
File: backend/src/services/ownershipMatrix.service.ts
Methods:
assignOwner(alertId, ownerId, ownerType, actorId)— Creates or updates ownership; writes audit log; validates alert existsgetOwner(alertId)— Returns current owner recordgetOwnershipMatrix(filters: { teamId?, ownerId?, alertId? }, pagination)— Returns filtered ownership matrix with paginationaddEscalationContact(alertId, contactUserId, order, actorId)— Adds escalation contact; writes audit loggetEscalationContacts(alertId)— Returns escalation contacts ordered byorderASCremoveEscalationContact(alertId, contactUserId, actorId)— Removes contact; writes audit loggetAuditHistory(alertId, pagination)— Queriesaudit_logsfiltered byresource_id = alertIdandresource_type IN ('alert_ownership', 'escalation_contact'), ordered bycreated_at DESCexportOwnershipMatrix(format: 'csv' | 'json', filters)— Exports filtered matrix; CSV usescsv-stringify; JSON returns same shape asgetOwnershipMatrixsearchOwnership(query: string, pagination)— ILIKE search acrossalert_rules.name,alert_ownership.owner_id, and joined team names (if team table exists)
Transaction Usage: All multi-table writes (assign + audit, add contact + audit) wrapped in db.transaction()
Error Handling: Throw descriptive errors; controllers map to HTTP status codes
File: backend/src/api/routes/ownershipMatrix.ts
Routes:
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /alerts/:alertId/ownership |
admin or current owner | Assign/transfer owner |
| GET | /alerts/:alertId/ownership |
authenticated | Get current owner |
| GET | /ownership/matrix |
authenticated | Get full matrix (filtered, paginated) |
| POST | /alerts/:alertId/escalation |
admin or current owner | Add escalation contact |
| GET | /alerts/:alertId/escalation |
authenticated | Get escalation contacts |
| DELETE | /alerts/:alertId/escalation/:contactId |
admin or current owner | Remove escalation contact |
| GET | /alerts/:alertId/ownership/history |
authenticated | Get audit history (paginated) |
| GET | /ownership/export |
admin | Export matrix (query params: format, filters) |
| GET | /ownership/search |
authenticated | Search ownership (query param: q, pagination) |
Validation: Zod schemas in backend/src/api/validations/ownershipMatrix.schema.ts
Authentication:
- All endpoints require
authMiddleware() - Admin-only endpoints:
authMiddleware({ requiredScopes: ["admin:ownership"] }) - Owner-check endpoints: Service validates
actorIdmatches current owner or has admin scope
getOwnershipMatrix will accept an optional groupBy: 'team' parameter. When set:
- Response shape:
{ teams: [{ teamId, teamName, alerts: [...] }] } - Implementation: SQL
GROUP BY owner_id WHERE owner_type = 'team'
File: backend/docs/alert-ownership-matrix.md
Sections:
- Overview
- Ownership Workflow (assign, transfer, escalate)
- API Endpoints (with examples)
- Audit History Semantics (append-only, tamper detection)
- Export Formats (CSV columns, JSON structure)
- Search Behavior (ILIKE, searchable fields)
- Authentication Requirements (per endpoint)
Service Tests (backend/tests/services/ownershipMatrix.service.test.ts):
assignOwnercreates ownership record and audit log entryassignOwneron already-owned alert records previous owner in audit log (transfer)assignOwnerrejects invalidalertIdaddEscalationContactadds contact at correct ordergetEscalationContactsreturns contacts in ascending ordergetAuditHistoryreturns entries in reverse chronological orderexportOwnershipMatrixCSV output includes correct headers and all rowsexportOwnershipMatrixJSON output matchesgetOwnershipMatrixshapesearchOwnershipreturns results matching alert name, owner name, and team name
Controller Tests (backend/tests/api/ownershipMatrix.test.ts):
- Every endpoint returns 401 for unauthenticated requests
- Every endpoint returns 400 for malformed requests
- Transfer endpoint correctly updates ownership and audit log
- Export endpoint streams CSV content with correct
Content-Typeheader - Search endpoint returns paginated results
- Audit history immutability: Assert no endpoint allows modification or deletion of audit log entries
Coverage Target: 90% for new code paths (service and controller)
- Type-check:
npm --workspace=backend run build(zero errors) - Lint:
npm --workspace=backend run lint(zero errors) - Migrations:
npm --workspace=backend run migrate(clean test database) - Tests:
npm --workspace=backend run test -- --coverage(all pass, 90%+ coverage) - Migration Validation:
npm --workspace=backend run migrate:validate(if available)
- Owner IDs: Never logged at production level (use
logger.debug()if needed) - Audit Log: Append-only enforced at service layer (no update/delete methods)
- Export Endpoint: Admin-restricted via
requiredScopes: ["admin:ownership"] - Ownership Verification: All modify endpoints verify
actorIdmatches current owner or has admin scope
backend/src/database/migrations/027_alert_ownership_matrix.tsbackend/src/services/ownershipMatrix.service.tsbackend/src/api/routes/ownershipMatrix.tsbackend/src/api/validations/ownershipMatrix.schema.tsbackend/tests/services/ownershipMatrix.service.test.tsbackend/tests/api/ownershipMatrix.test.tsbackend/docs/alert-ownership-matrix.md
backend/src/api/routes/index.ts— RegisterownershipMatrixroutesbackend/src/services/audit.service.ts— Add new audit action types (if not already generic)
- Team Data Model: Does a
teamstable exist? If not,owner_type = 'team'will store team identifiers as strings without FK constraint. Search will be limited toowner_idILIKE. - Admin Scope Definition: What scope string should be used for admin checks? Assuming
admin:ownershipbased on existingadmin:auditpattern. - Escalation Contact Ordering: Should reordering existing contacts be supported, or only add/remove? Assuming add/remove only for MVP.
- Export Size Limits: Should large exports use async job queue (like
export.service.ts)? Assuming direct streaming for MVP (ownership matrix expected to be <10k rows).
This implementation follows all existing patterns in the Bridge-Watch codebase:
- Knex migrations with UUID primary keys and snake_case columns
- Class-based services with transaction-wrapped multi-table writes
- Fastify routes with Zod validation and
authMiddleware() - Reuses existing
audit_logstable (append-only, tamper-proof) - CSV export via
csv-stringify, JSON export as direct response - ILIKE-based search following existing search patterns
- Vitest tests with mocked services and
server.inject()for routes - All CI checks (lint, build, migrate, test) will pass before PR
Branch: feature/backend-alert-ownership
Closes: #465