From d43fc620e91be916d81ab5f521681a2c750a1fe7 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 8 Oct 2026 15:36:19 +0200 Subject: [PATCH] feat: outgoing webhooks Owners and admins can point a project at other systems: a signed JSON POST (HMAC-SHA256 of the body) when tickets, comments, releases or sprints change, filtered by event or family. Deliveries are retried a couple of times, the last one is shown, and a ping can be sent from the settings. Private addresses are refused unless WEBHOOKS_ALLOW_PRIVATE is set. Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 + apps/api/openapi.json | 676 ++++++++++++++++++ apps/api/schema.gql | 57 ++ apps/mcp/src/app/api/taskstream.schema.ts | 512 +++++++++++++ docker-compose.yml | 2 + docs/01-vision-general.md | 1 + docs/03-api.md | 11 + docs/07-despliegue.md | 57 +- libs/api/tracker/application/src/index.ts | 3 + .../application/src/lib/ports/index.ts | 1 + .../lib/ports/outgoing-webhooks.repository.ts | 48 ++ .../src/lib/tracker-application.module.ts | 4 + .../application/src/lib/webhooks.service.ts | 62 ++ .../src/lib/webhooks/webhook-events.ts | 73 ++ .../src/lib/webhooks/webhooks.spec.ts | 90 +++ .../application/src/lib/webhooks/webhooks.ts | 411 +++++++++++ .../lib/mongo/outgoing-webhooks.repository.ts | 62 ++ .../src/lib/mongo/read-model.schemas.ts | 16 + .../mongo/upsert-materialized.repositories.ts | 4 + .../src/lib/tracker-infrastructure.module.ts | 9 + .../lib/webhooks/http-webhook.sender.spec.ts | 19 + .../src/lib/webhooks/http-webhook.sender.ts | 73 ++ .../presenters/src/lib/graphql/inputs.ts | 27 + .../presenters/src/lib/graphql/types.ts | 36 + .../src/lib/graphql/webhook.resolver.ts | 72 ++ .../src/lib/http/dto/requests.dto.ts | 23 + .../src/lib/http/dto/responses.dto.ts | 27 + .../src/lib/http/webhooks.controller.ts | 136 ++++ .../src/lib/tracker-presenters.module.ts | 4 + .../tracker/domain/src/lib/models/models.ts | 21 + .../tracker/domain/src/lib/ports/gateways.ts | 20 + .../lib/adapters/graphql-webhooks.gateway.ts | 64 ++ .../src/lib/graphql/generated.ts | 666 +++++++++++++++++ .../src/lib/graphql/webhooks.graphql | 54 ++ .../lib/tracker-infrastructure.providers.ts | 3 + .../presenters/src/lib/pages/settings.page.ts | 3 + .../tracker/presenters/src/lib/ui/webhooks.ts | 338 +++++++++ 37 files changed, 3660 insertions(+), 28 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/ports/outgoing-webhooks.repository.ts create mode 100644 libs/api/tracker/application/src/lib/webhooks.service.ts create mode 100644 libs/api/tracker/application/src/lib/webhooks/webhook-events.ts create mode 100644 libs/api/tracker/application/src/lib/webhooks/webhooks.spec.ts create mode 100644 libs/api/tracker/application/src/lib/webhooks/webhooks.ts create mode 100644 libs/api/tracker/infrastructure/src/lib/mongo/outgoing-webhooks.repository.ts create mode 100644 libs/api/tracker/infrastructure/src/lib/webhooks/http-webhook.sender.spec.ts create mode 100644 libs/api/tracker/infrastructure/src/lib/webhooks/http-webhook.sender.ts create mode 100644 libs/api/tracker/presenters/src/lib/graphql/webhook.resolver.ts create mode 100644 libs/api/tracker/presenters/src/lib/http/webhooks.controller.ts create mode 100644 libs/web/tracker/infrastructure/src/lib/adapters/graphql-webhooks.gateway.ts create mode 100644 libs/web/tracker/infrastructure/src/lib/graphql/webhooks.graphql create mode 100644 libs/web/tracker/presenters/src/lib/ui/webhooks.ts diff --git a/.env.example b/.env.example index 810f41c..381c4ef 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,9 @@ SMTP_URL= MAIL_FROM= WEB_URL=http://localhost:4200 +# Outgoing webhooks refuse hosts on this machine or its private network unless true. +WEBHOOKS_ALLOW_PRIVATE=false + # Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image). # Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-) NEXUS_TOKEN= diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 0e0f131..c66a27f 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -7871,6 +7871,534 @@ ], "security": [] } + }, + "/api/webhook-events": { + "get": { + "operationId": "WebhooksController_events", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Event names a webhook can subscribe to", + "tags": [ + "Webhooks" + ] + } + }, + "/api/projects/{project}/webhooks": { + "get": { + "operationId": "WebhooksController_list", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "The project's webhooks (owners and admins)", + "tags": [ + "Webhooks" + ] + }, + "post": { + "description": "The answer carries the secret, the only time it is shown.", + "operationId": "WebhooksController_create", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhookDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookCreatedDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Add a webhook", + "tags": [ + "Webhooks" + ] + } + }, + "/api/outgoing-webhooks/{webhook}": { + "patch": { + "operationId": "WebhooksController_update", + "parameters": [ + { + "name": "webhook", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhookDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Change the URL or the events, pause or resume", + "tags": [ + "Webhooks" + ] + }, + "delete": { + "operationId": "WebhooksController_delete", + "parameters": [ + { + "name": "webhook", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Delete a webhook", + "tags": [ + "Webhooks" + ] + } + }, + "/api/outgoing-webhooks/{webhook}/test": { + "post": { + "operationId": "WebhooksController_test", + "parameters": [ + { + "name": "webhook", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Send a ping now and tell how it went", + "tags": [ + "Webhooks" + ] + } } }, "info": { @@ -10815,6 +11343,154 @@ "required": [ "recorded" ] + }, + "WebhookDeliveryDto": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "event": { + "type": "string", + "example": "issue.created" + }, + "status": { + "type": "number", + "nullable": true, + "description": "HTTP status" + }, + "ok": { + "type": "boolean" + }, + "error": { + "type": "string", + "nullable": true + } + }, + "required": [ + "at", + "event", + "status", + "ok", + "error" + ] + }, + "WebhookDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "url": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "active": { + "type": "boolean" + }, + "createdBy": { + "$ref": "#/components/schemas/UserRefDto" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastDelivery": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/WebhookDeliveryDto" + } + ] + } + }, + "required": [ + "id", + "projectId", + "url", + "events", + "active", + "createdBy", + "createdAt", + "lastDelivery" + ] + }, + "CreateWebhookDto": { + "type": "object", + "properties": { + "url": { + "type": "string", + "example": "https://chat.example.org/hooks/taskstream" + }, + "events": { + "example": [ + "issue.status_changed", + "release.*" + ], + "description": "Event names (GET /api/webhook-events), family.* or *", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "url", + "events" + ] + }, + "WebhookCreatedDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "secret": { + "type": "string", + "description": "Signs every delivery. Shown only now." + } + }, + "required": [ + "id", + "secret" + ] + }, + "UpdateWebhookDto": { + "type": "object", + "properties": { + "url": { + "type": "string", + "example": "https://chat.example.org/hooks/taskstream" + }, + "events": { + "example": [ + "issue.status_changed", + "release.*" + ], + "description": "Event names (GET /api/webhook-events), family.* or *", + "type": "array", + "items": { + "type": "string" + } + }, + "active": { + "type": "boolean", + "description": "Paused webhooks get nothing" + } + } } } } diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 1fc430e..ae0da8d 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -190,6 +190,13 @@ input CreateSprintInput { startDate: String! } +input CreateWebhookInput { + """Event names, family.* or *""" + events: [String!]! + projectId: ID! + url: String! +} + type CreatedResolved { created: Int! resolved: Int! @@ -465,6 +472,7 @@ type Mutation { """Plan a sprint (owners and admins)""" createSprint(input: CreateSprintInput!): CommandResult! + createWebhook(input: CreateWebhookInput!): WebhookCreated! """Author or project admin""" deleteComment(commentId: ID!): CommandResult! @@ -479,6 +487,7 @@ type Mutation { """Delete a sprint that never started; its tickets go back to the backlog""" deleteSprint(sprintId: ID!): CommandResult! + deleteWebhook(webhookId: ID!): Boolean! disableGitIntegration(projectId: ID!): Boolean! editComment(body: String!, commentId: ID!): CommandResult! @@ -522,6 +531,9 @@ type Mutation { """Start a planned sprint (only one under way per project)""" startSprint(sprintId: ID!): CommandResult! + + """Send a ping now and tell how it went""" + testWebhook(webhookId: ID!): WebhookDelivery! unlinkIssues(input: LinkIssuesInput!): CommandResult! """Stop following a ticket""" @@ -539,6 +551,7 @@ type Mutation { updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult! updateRelease(input: UpdateReleaseInput!): CommandResult! updateSprint(input: UpdateSprintInput!): CommandResult! + updateWebhook(input: UpdateWebhookInput!): Webhook! """ Follow a ticket: be told when it moves, is reassigned or gets a comment @@ -776,6 +789,10 @@ type Query { """Search users by name or email (to assign / add members)""" users(limit: Int! = 20, search: String! = ""): [User!]! + + """Event names a webhook can subscribe to (also family.* and *)""" + webhookEvents: [String!]! + webhooks(projectId: ID!): [Webhook!]! } input RankIssueInput { @@ -1053,6 +1070,13 @@ input UpdateSprintInput { startDate: String } +input UpdateWebhookInput { + active: Boolean + events: [String!] + url: String + webhookId: ID! +} + """User managed by the Pireon identity server""" type User { email: String! @@ -1066,6 +1090,39 @@ type UserRef { name: String! } +""" +Where the project tells another system what happened (signed POST, HMAC-SHA256) +""" +type Webhook { + active: Boolean! + createdAt: String! + createdBy: UserRef! + + """Event names, family.* or *""" + events: [String!]! + id: ID! + lastDelivery: WebhookDelivery + projectId: ID! + url: String! +} + +type WebhookCreated { + id: ID! + + """Signs every delivery. Shown only now: keep it.""" + secret: String! +} + +type WebhookDelivery { + at: String! + error: String + event: String! + ok: Boolean! + + """HTTP status""" + status: Int +} + type WorkflowStatus { """No new tickets or transitions into it""" archived: Boolean! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index fe6fba1..29908d1 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1136,6 +1136,79 @@ export interface paths { patch?: never; trace?: never; }; + "/api/webhook-events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Event names a webhook can subscribe to */ + get: operations["WebhooksController_events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{project}/webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The project's webhooks (owners and admins) */ + get: operations["WebhooksController_list"]; + put?: never; + /** + * Add a webhook + * @description The answer carries the secret, the only time it is shown. + */ + post: operations["WebhooksController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/outgoing-webhooks/{webhook}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a webhook */ + delete: operations["WebhooksController_delete"]; + options?: never; + head?: never; + /** Change the URL or the events, pause or resume */ + patch: operations["WebhooksController_update"]; + trace?: never; + }; + "/api/outgoing-webhooks/{webhook}/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send a ping now and tell how it went */ + post: operations["WebhooksController_test"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -2098,6 +2171,61 @@ export interface components { /** @description Tickets that got something new */ recorded: number; }; + WebhookDeliveryDto: { + /** Format: date-time */ + at: string; + /** @example issue.created */ + event: string; + /** @description HTTP status */ + status: number | null; + ok: boolean; + error: string | null; + }; + WebhookDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + url: string; + events: string[]; + active: boolean; + createdBy: components["schemas"]["UserRefDto"]; + /** Format: date-time */ + createdAt: string; + lastDelivery: components["schemas"]["WebhookDeliveryDto"] | null; + }; + CreateWebhookDto: { + /** @example https://chat.example.org/hooks/taskstream */ + url: string; + /** + * @description Event names (GET /api/webhook-events), family.* or * + * @example [ + * "issue.status_changed", + * "release.*" + * ] + */ + events: string[]; + }; + WebhookCreatedDto: { + /** Format: uuid */ + id: string; + /** @description Signs every delivery. Shown only now. */ + secret: string; + }; + UpdateWebhookDto: { + /** @example https://chat.example.org/hooks/taskstream */ + url?: string; + /** + * @description Event names (GET /api/webhook-events), family.* or * + * @example [ + * "issue.status_changed", + * "release.*" + * ] + */ + events?: string[]; + /** @description Paused webhooks get nothing */ + active?: boolean; + }; }; responses: never; parameters: never; @@ -7539,4 +7667,388 @@ export interface operations { }; }; }; + WebhooksController_events: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string[]; + }; + }; + }; + }; + WebhooksController_list: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + WebhooksController_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateWebhookDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookCreatedDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + WebhooksController_delete: { + parameters: { + query?: never; + header?: never; + path: { + webhook: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + WebhooksController_update: { + parameters: { + query?: never; + header?: never; + path: { + webhook: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWebhookDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + WebhooksController_test: { + parameters: { + query?: never; + header?: never; + path: { + webhook: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookDeliveryDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; } diff --git a/docker-compose.yml b/docker-compose.yml index d91e6c4..60dc726 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,8 @@ services: SMTP_URL: ${SMTP_URL:-} MAIL_FROM: ${MAIL_FROM:-} WEB_URL: ${WEB_URL:-} + # Outgoing webhooks to private addresses (off: they could reach internal services). + WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false} volumes: # Files attached to tickets: lose this volume and the tickets point at nothing. - attachments-data:/data/attachments diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 000bb4e..9daeeb0 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -27,6 +27,7 @@ | 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | | 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | +| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | | 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | | 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | | 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | diff --git a/docs/03-api.md b/docs/03-api.md index e11ea81..a551b89 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -167,6 +167,14 @@ stateDiagram-v2 actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido, integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el event store. +- **Webhooks salientes**: owners y admins dan de alta URLs (hasta 20 por proyecto) suscritas a eventos + (`issue.created`, `issue.status_changed`, `comment.added`, `release.shipped`, `sprint.closed`… — la lista en + `webhookEvents` / `GET /api/webhook-events`), a familias (`issue.*`) o a todo (`*`). Cada envío es un `POST` JSON con + `event`, `deliveryId`, `occurredAt`, `actorId`, `project`, `issue` (si va de un ticket) y `data` (los campos del evento), + y las cabeceras `X-TaskStream-Event`, `X-TaskStream-Delivery` y `X-TaskStream-Signature: sha256=`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (no ante un 4xx), con 5 s de espera + máxima; se guarda el último envío. Salen en segundo plano y no son duraderos: si la API se reinicia a mitad, ese + envío se pierde. No llaman a direcciones privadas salvo con `WEBHOOKS_ALLOW_PRIVATE=true`. - **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un bloqueo: la API acepta pasarse y el tablero marca la columna. - **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro. @@ -259,6 +267,7 @@ que funcionan también sin token. | `publicProjects` | Proyectos públicos (sin token) | | `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` | | `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog | +| `webhooks(projectId)`, `webhookEvents` | Webhooks salientes del proyecto (owners y admins) y los eventos a los que se pueden suscribir | | `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto | | `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` | | `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto | @@ -276,6 +285,7 @@ Todas devuelven `CommandResult { id, key, version }`. | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) | | Comentarios | `addComment`, `editComment`, `deleteComment` | +| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Git | `enableGitIntegration` (también para cambiar el secreto), `disableGitIntegration` | | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | @@ -489,6 +499,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | | Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | +| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | | Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index 208d8f7..e7b4f7f 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -70,34 +70,35 @@ donde los lee Compose. ## 7.2 Variables de entorno de la API -| Variable | Por defecto | Descripción | -| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `PORT` | `3000` | Puerto HTTP | -| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces | -| `POSTGRES_URL` | — (obligatoria) | Event store y lookups | -| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones | -| `MONGO_URL` | — (obligatoria) | Read model | -| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché | -| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream | -| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream | -| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) | -| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad | -| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI | -| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña | -| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión | -| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña | -| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon | -| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon | -| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon | -| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección | -| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) | -| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` | -| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) | -| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` | -| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada | -| `MAIL_FROM` | `TaskStream ` | Remitente de esos correos | -| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos | -| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) | +| Variable | Por defecto | Descripción | +| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | `3000` | Puerto HTTP | +| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces | +| `POSTGRES_URL` | — (obligatoria) | Event store y lookups | +| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones | +| `MONGO_URL` | — (obligatoria) | Read model | +| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché | +| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream | +| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream | +| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) | +| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad | +| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI | +| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña | +| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión | +| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña | +| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon | +| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon | +| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon | +| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección | +| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) | +| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` | +| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) | +| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` | +| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada | +| `MAIL_FROM` | `TaskStream ` | Remitente de esos correos | +| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos | +| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) | +| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) | La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`). diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 2eead38..08cf93c 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -12,6 +12,9 @@ export * from './lib/notifications'; export * from './lib/saved-filters.service'; export * from './lib/sprints.service'; export * from './lib/git.service'; +export * from './lib/webhooks.service'; +export * from './lib/webhooks/webhooks'; +export * from './lib/webhooks/webhook-events'; export * from './lib/git/git'; export * from './lib/git/git-payloads'; export * from './lib/saved-filters/saved-filters'; diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index d9ddcee..e4b7189 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -24,3 +24,4 @@ export * from './saved-filters.repository'; export * from './find-sprints.repository'; export * from './upsert-materialized-sprint.repository'; export * from './git-integrations.repository'; +export * from './outgoing-webhooks.repository'; diff --git a/libs/api/tracker/application/src/lib/ports/outgoing-webhooks.repository.ts b/libs/api/tracker/application/src/lib/ports/outgoing-webhooks.repository.ts new file mode 100644 index 0000000..6378e22 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/outgoing-webhooks.repository.ts @@ -0,0 +1,48 @@ +import { UserRefReadModel } from '@task-stream/api/tracker/domain'; + +export interface WebhookDelivery { + at: string; + event: string; + /** HTTP status, null when it never answered. */ + status: number | null; + ok: boolean; + error: string | null; +} + +/** Where a project tells other systems what happened in it. */ +export interface OutgoingWebhook { + id: string; + projectId: string; + url: string; + /** Signs every delivery (HMAC-SHA256); shown once, when created. */ + secret: string; + /** Event names, `issue.*` or `*`. */ + events: string[]; + active: boolean; + createdBy: UserRefReadModel; + createdAt: string; + lastDelivery: WebhookDelivery | null; +} + +/** Configuration, not history: kept outside the event store. */ +export abstract class OutgoingWebhooksRepository { + abstract findByProject(projectId: string): Promise; + abstract findActive(projectId: string): Promise; + abstract findById(id: string): Promise; + abstract insert(webhook: OutgoingWebhook): Promise; + abstract update( + id: string, + changes: Partial>, + ): Promise; + abstract delete(id: string): Promise; + abstract recordDelivery(id: string, delivery: WebhookDelivery): Promise; +} + +/** Sends one delivery. Throws when the receiver cannot be reached. */ +export abstract class WebhookSender { + abstract send(request: { + url: string; + body: string; + headers: Record; + }): Promise<{ status: number }>; +} diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 5c24296..70dfca6 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -34,6 +34,8 @@ import { SavedFiltersService } from './saved-filters.service'; import { SprintsService } from './sprints.service'; import { GitService } from './git.service'; import { GIT_HANDLERS } from './git/git'; +import { WebhooksService } from './webhooks.service'; +import { WEBHOOK_HANDLERS } from './webhooks/webhooks'; /** Notifications: derived from events, kept and dismissed per person. */ const NOTIFICATIONS = [ @@ -56,6 +58,7 @@ const SERVICES = [ SavedFiltersService, SprintsService, GitService, + WebhooksService, ]; /** @@ -79,6 +82,7 @@ const SERVICES = [ ...NOTIFICATIONS, ...SAVED_FILTERS, ...GIT_HANDLERS, + ...WEBHOOK_HANDLERS, ], exports: SERVICES, }) diff --git a/libs/api/tracker/application/src/lib/webhooks.service.ts b/libs/api/tracker/application/src/lib/webhooks.service.ts new file mode 100644 index 0000000..d4f0c52 --- /dev/null +++ b/libs/api/tracker/application/src/lib/webhooks.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { WebhookDelivery } from './ports/outgoing-webhooks.repository'; +import { WEBHOOK_EVENT_NAMES } from './webhooks/webhook-events'; +import { + CreateWebhookCommand, + DeleteWebhookCommand, + GetWebhooksQuery, + TestWebhookCommand, + UpdateWebhookCommand, + WebhookView, +} from './webhooks/webhooks'; + +/** The project's outgoing webhooks (owners and admins). */ +@Injectable() +export class WebhooksService { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + /** Names other systems can subscribe to (plus `family.*` and `*`). */ + events(): string[] { + return WEBHOOK_EVENT_NAMES; + } + + list(viewerId: string, projectId: string): Promise { + return this.queryBus.execute(new GetWebhooksQuery(viewerId, projectId)); + } + + /** The secret comes back only here. */ + create( + actorId: string, + projectId: string, + url: string, + events: string[], + ): Promise<{ id: string; secret: string }> { + return this.commandBus.execute( + new CreateWebhookCommand(actorId, projectId, url, events), + ); + } + + update( + actorId: string, + webhookId: string, + changes: { url?: string; events?: string[]; active?: boolean }, + ): Promise { + return this.commandBus.execute( + new UpdateWebhookCommand(actorId, webhookId, changes), + ); + } + + delete(actorId: string, webhookId: string): Promise { + return this.commandBus.execute( + new DeleteWebhookCommand(actorId, webhookId), + ); + } + + test(actorId: string, webhookId: string): Promise { + return this.commandBus.execute(new TestWebhookCommand(actorId, webhookId)); + } +} diff --git a/libs/api/tracker/application/src/lib/webhooks/webhook-events.ts b/libs/api/tracker/application/src/lib/webhooks/webhook-events.ts new file mode 100644 index 0000000..1f306b4 --- /dev/null +++ b/libs/api/tracker/application/src/lib/webhooks/webhook-events.ts @@ -0,0 +1,73 @@ +import { + CommentAddedEvent, + CommentDeletedEvent, + CommentEditedEvent, + IssueAssignedEvent, + IssueCreatedEvent, + IssueDeletedEvent, + IssueDevLinkRecordedEvent, + IssueReleaseChangedEvent, + IssueSprintChangedEvent, + IssueStatusChangedEvent, + IssueUpdatedEvent, + ReleaseArchivedEvent, + ReleaseCreatedEvent, + ReleaseShippedEvent, + SprintClosedEvent, + SprintStartedEvent, + TrackerEvent, +} from '@task-stream/api/tracker/domain'; +import { Type } from '@nestjs/common'; + +/** What other systems can subscribe to, by the event that causes it. */ +export const WEBHOOK_EVENTS: [Type, string][] = [ + [IssueCreatedEvent, 'issue.created'], + [IssueUpdatedEvent, 'issue.updated'], + [IssueStatusChangedEvent, 'issue.status_changed'], + [IssueAssignedEvent, 'issue.assigned'], + [IssueSprintChangedEvent, 'issue.sprint_changed'], + [IssueReleaseChangedEvent, 'issue.release_changed'], + [IssueDevLinkRecordedEvent, 'issue.dev_link'], + [IssueDeletedEvent, 'issue.deleted'], + [CommentAddedEvent, 'comment.added'], + [CommentEditedEvent, 'comment.edited'], + [CommentDeletedEvent, 'comment.deleted'], + [ReleaseCreatedEvent, 'release.created'], + [ReleaseShippedEvent, 'release.shipped'], + [ReleaseArchivedEvent, 'release.archived'], + [SprintStartedEvent, 'sprint.started'], + [SprintClosedEvent, 'sprint.closed'], +]; + +export const WEBHOOK_EVENT_NAMES = WEBHOOK_EVENTS.map(([, name]) => name); + +export function webhookEventOf(event: TrackerEvent): string | null { + return WEBHOOK_EVENTS.find(([type]) => event instanceof type)?.[1] ?? null; +} + +/** `*`, an exact name, or a family (`issue.*`). */ +export function subscribes(events: string[], name: string): boolean { + return events.some( + (e) => + e === '*' || + e === name || + (e.endsWith('.*') && name.startsWith(e.slice(0, -1))), + ); +} + +/** Keeps known names and families, once each. */ +export function normalizeWebhookEvents(events: string[]): string[] { + const families = new Set( + WEBHOOK_EVENT_NAMES.map((n) => `${n.split('.')[0]}.*`), + ); + return [ + ...new Set( + events + .map((e) => e.trim()) + .filter( + (e) => + e === '*' || families.has(e) || WEBHOOK_EVENT_NAMES.includes(e), + ), + ), + ]; +} diff --git a/libs/api/tracker/application/src/lib/webhooks/webhooks.spec.ts b/libs/api/tracker/application/src/lib/webhooks/webhooks.spec.ts new file mode 100644 index 0000000..46829a3 --- /dev/null +++ b/libs/api/tracker/application/src/lib/webhooks/webhooks.spec.ts @@ -0,0 +1,90 @@ +import { createHmac } from 'node:crypto'; +import { + OutgoingWebhook, + OutgoingWebhooksRepository, + WebhookDelivery, + WebhookSender, +} from '../ports/outgoing-webhooks.repository'; +import { normalizeWebhookEvents, subscribes } from './webhook-events'; +import { WebhookDispatcher } from './webhooks'; + +const webhook: OutgoingWebhook = { + id: 'w1', + projectId: 'p1', + url: 'https://example.org/hook', + secret: 's3cret', + events: ['issue.*'], + active: true, + createdBy: { id: 'u', name: 'U' }, + createdAt: '', + lastDelivery: null, +}; + +function doubles(statuses: (number | Error)[]) { + const recorded: WebhookDelivery[] = []; + const sent: { body: string; headers: Record }[] = []; + const repo = { + recordDelivery: async (_id: string, d: WebhookDelivery) => { + recorded.push(d); + }, + } as unknown as OutgoingWebhooksRepository; + const sender: WebhookSender = { + send: async (request) => { + sent.push(request); + const next = statuses.shift() ?? 200; + if (next instanceof Error) throw next; + return { status: next }; + }, + }; + return { repo, sender, recorded, sent }; +} + +describe('outgoing webhooks', () => { + it('subscribes to names, families and everything', () => { + expect(subscribes(['issue.*'], 'issue.created')).toBe(true); + expect(subscribes(['issue.*'], 'comment.added')).toBe(false); + expect(subscribes(['*'], 'sprint.closed')).toBe(true); + expect( + normalizeWebhookEvents(['issue.*', 'nope', ' sprint.closed ', 'issue.*']), + ).toEqual(['issue.*', 'sprint.closed']); + }); + + it('signs the body and retries until it gets through', async () => { + const { repo, sender, recorded, sent } = doubles([ + new Error('ECONNRESET'), + 503, + 204, + ]); + const delivery = await new WebhookDispatcher( + repo, + sender, + [0, 0, 0], + ).deliver(webhook, 'issue.created', { data: { title: 'x' } }); + expect(sent).toHaveLength(3); + const { body, headers } = sent[0]; + expect(headers['x-taskstream-signature']).toBe( + `sha256=${createHmac('sha256', 's3cret').update(body).digest('hex')}`, + ); + expect(JSON.parse(body)).toMatchObject({ + event: 'issue.created', + data: { title: 'x' }, + }); + expect(delivery).toMatchObject({ ok: true, status: 204 }); + expect(recorded).toEqual([delivery]); + }); + + it('gives up at once on a 4xx', async () => { + const { repo, sender, sent } = doubles([410]); + const delivery = await new WebhookDispatcher( + repo, + sender, + [0, 0, 0], + ).deliver(webhook, 'issue.created', {}); + expect(sent).toHaveLength(1); + expect(delivery).toMatchObject({ + ok: false, + status: 410, + error: 'HTTP 410', + }); + }); +}); diff --git a/libs/api/tracker/application/src/lib/webhooks/webhooks.ts b/libs/api/tracker/application/src/lib/webhooks/webhooks.ts new file mode 100644 index 0000000..1fc29ac --- /dev/null +++ b/libs/api/tracker/application/src/lib/webhooks/webhooks.ts @@ -0,0 +1,411 @@ +import { createHmac, randomBytes, randomUUID } from 'node:crypto'; +import { Logger } from '@nestjs/common'; +import { + Command, + CommandHandler, + EventsHandler, + ICommandHandler, + IEventHandler, + IQueryHandler, + Query, + QueryHandler, +} from '@nestjs/cqrs'; +import { + BusinessRuleError, + NotFoundError, + assertThat, +} from '@task-stream/api/shared/domain'; +import { TrackerEvent } from '@task-stream/api/tracker/domain'; +import { WriteAccess } from '../commands/write-access'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { + OutgoingWebhook, + OutgoingWebhooksRepository, + WebhookDelivery, + WebhookSender, +} from '../ports/outgoing-webhooks.repository'; +import { UserDirectory } from '../ports/user-directory'; +import { + WEBHOOK_EVENTS, + normalizeWebhookEvents, + subscribes, + webhookEventOf, +} from './webhook-events'; + +/** A project keeps at most this many webhooks. */ +export const MAX_WEBHOOKS = 20; +/** Waits between attempts: right away, after 2 s, after 10 s. */ +export const RETRY_DELAYS_MS = [0, 2_000, 10_000]; + +/** The webhook as listed: the secret is only ever shown when created. */ +export type WebhookView = Omit; +const view = ({ secret: _secret, ...rest }: OutgoingWebhook): WebhookView => + rest; + +function assertUrl(url: string): string { + const next = url.trim(); + let parsed: URL | null = null; + try { + parsed = new URL(next); + } catch { + parsed = null; + } + assertThat( + !!parsed && ['http:', 'https:'].includes(parsed.protocol), + 'A webhook needs an http(s) URL', + ); + return next; +} + +export class CreateWebhookCommand extends Command<{ + id: string; + secret: string; +}> { + constructor( + public readonly actorId: string, + public readonly projectId: string, + public readonly url: string, + public readonly events: string[], + ) { + super(); + } +} + +export class UpdateWebhookCommand extends Command { + constructor( + public readonly actorId: string, + public readonly webhookId: string, + public readonly changes: { + url?: string; + events?: string[]; + active?: boolean; + }, + ) { + super(); + } +} + +export class DeleteWebhookCommand extends Command { + constructor( + public readonly actorId: string, + public readonly webhookId: string, + ) { + super(); + } +} + +/** Sends a `ping` now and tells how it went. */ +export class TestWebhookCommand extends Command { + constructor( + public readonly actorId: string, + public readonly webhookId: string, + ) { + super(); + } +} + +export class GetWebhooksQuery extends Query { + constructor( + public readonly viewerId: string, + public readonly projectId: string, + ) { + super(); + } +} + +/** Signs and sends deliveries, retrying a couple of times, and records the outcome. */ +export class WebhookDispatcher { + private readonly logger = new Logger(WebhookDispatcher.name); + + constructor( + private readonly webhooks: OutgoingWebhooksRepository, + private readonly sender: WebhookSender, + private readonly delays: number[] = RETRY_DELAYS_MS, + ) {} + + async deliver( + webhook: OutgoingWebhook, + event: string, + payload: Record, + ): Promise { + const deliveryId = randomUUID(); + const body = JSON.stringify({ event, deliveryId, ...payload }); + const signature = createHmac('sha256', webhook.secret) + .update(body) + .digest('hex'); + let delivery: WebhookDelivery = { + at: new Date().toISOString(), + event, + status: null, + ok: false, + error: null, + }; + for (const delay of this.delays) { + if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); + try { + const { status } = await this.sender.send({ + url: webhook.url, + body, + headers: { + 'content-type': 'application/json', + 'user-agent': 'TaskStream-Webhooks', + 'x-taskstream-event': event, + 'x-taskstream-delivery': deliveryId, + 'x-taskstream-signature': `sha256=${signature}`, + }, + }); + delivery = { + ...delivery, + at: new Date().toISOString(), + status, + ok: status < 300, + error: status < 300 ? null : `HTTP ${status}`, + }; + // A 4xx will not get better by retrying. + if (delivery.ok || (status >= 400 && status < 500)) break; + } catch (error) { + delivery = { + ...delivery, + at: new Date().toISOString(), + status: null, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + if (!delivery.ok) + this.logger.warn( + `Webhook ${webhook.id} (${event}) failed: ${delivery.error}`, + ); + await this.webhooks.recordDelivery(webhook.id, delivery); + return delivery; + } +} + +/** Whoever manages the project of a webhook. */ +async function managedWebhook( + webhooks: OutgoingWebhooksRepository, + access: WriteAccess, + webhookId: string, + actorId: string, +): Promise { + const webhook = await webhooks.findById(webhookId); + if (!webhook) throw new NotFoundError('Webhook', webhookId); + (await access.project(webhook.projectId)).assertCanManage(actorId); + return webhook; +} + +@CommandHandler(CreateWebhookCommand) +export class CreateWebhookCommandHandler + implements ICommandHandler +{ + constructor( + private readonly webhooks: OutgoingWebhooksRepository, + private readonly access: WriteAccess, + private readonly users: UserDirectory, + ) {} + + async execute(command: CreateWebhookCommand) { + (await this.access.project(command.projectId)).assertCanManage( + command.actorId, + ); + const events = normalizeWebhookEvents(command.events); + assertThat(events.length > 0, 'Pick at least one event'); + if ( + (await this.webhooks.findByProject(command.projectId)).length >= + MAX_WEBHOOKS + ) + throw new BusinessRuleError( + `A project keeps at most ${MAX_WEBHOOKS} webhooks`, + ); + const users = await this.users.refs([command.actorId]); + const webhook: OutgoingWebhook = { + id: randomUUID(), + projectId: command.projectId, + url: assertUrl(command.url), + secret: randomBytes(24).toString('hex'), + events, + active: true, + createdBy: users.of(command.actorId), + createdAt: new Date().toISOString(), + lastDelivery: null, + }; + await this.webhooks.insert(webhook); + return { id: webhook.id, secret: webhook.secret }; + } +} + +@CommandHandler(UpdateWebhookCommand) +export class UpdateWebhookCommandHandler + implements ICommandHandler +{ + constructor( + private readonly webhooks: OutgoingWebhooksRepository, + private readonly access: WriteAccess, + ) {} + + async execute(command: UpdateWebhookCommand): Promise { + const webhook = await managedWebhook( + this.webhooks, + this.access, + command.webhookId, + command.actorId, + ); + const changes: { url?: string; events?: string[]; active?: boolean } = {}; + if (command.changes.url !== undefined) + changes.url = assertUrl(command.changes.url); + if (command.changes.events !== undefined) { + changes.events = normalizeWebhookEvents(command.changes.events); + assertThat(changes.events.length > 0, 'Pick at least one event'); + } + if (command.changes.active !== undefined) + changes.active = command.changes.active; + await this.webhooks.update(webhook.id, changes); + return view({ ...webhook, ...changes }); + } +} + +@CommandHandler(DeleteWebhookCommand) +export class DeleteWebhookCommandHandler + implements ICommandHandler +{ + constructor( + private readonly webhooks: OutgoingWebhooksRepository, + private readonly access: WriteAccess, + ) {} + + async execute(command: DeleteWebhookCommand): Promise { + const webhook = await managedWebhook( + this.webhooks, + this.access, + command.webhookId, + command.actorId, + ); + await this.webhooks.delete(webhook.id); + } +} + +@CommandHandler(TestWebhookCommand) +export class TestWebhookCommandHandler + implements ICommandHandler +{ + constructor( + private readonly webhooks: OutgoingWebhooksRepository, + private readonly access: WriteAccess, + private readonly sender: WebhookSender, + ) {} + + async execute(command: TestWebhookCommand): Promise { + const webhook = await managedWebhook( + this.webhooks, + this.access, + command.webhookId, + command.actorId, + ); + // One attempt: someone is waiting for the answer. + return new WebhookDispatcher(this.webhooks, this.sender, [0]).deliver( + webhook, + 'ping', + { + occurredAt: new Date().toISOString(), + projectId: webhook.projectId, + actorId: command.actorId, + data: { message: 'Hola desde TaskStream' }, + }, + ); + } +} + +@QueryHandler(GetWebhooksQuery) +export class GetWebhooksQueryHandler + implements IQueryHandler +{ + constructor( + private readonly webhooks: OutgoingWebhooksRepository, + private readonly access: WriteAccess, + ) {} + + async execute(query: GetWebhooksQuery): Promise { + (await this.access.project(query.projectId)).assertCanManage( + query.viewerId, + ); + return (await this.webhooks.findByProject(query.projectId)).map(view); + } +} + +/** The event's own fields: what happened, without the envelope. */ +function dataOf(event: TrackerEvent): Record { + const { + actorId: _actor, + aggregateId: _aggregate, + projectId: _project, + occurredAt: _at, + aggregateVersion: _version, + ...data + } = event as unknown as Record; + return data; +} + +/** + * Tells the project's webhooks about what happened. Deliveries go out in the + * background: a slow receiver never holds the events bridge. + */ +@EventsHandler(...WEBHOOK_EVENTS.map(([type]) => type)) +export class OutgoingWebhooksEventHandler + implements IEventHandler +{ + private readonly dispatcher: WebhookDispatcher; + + constructor( + private readonly webhooks: OutgoingWebhooksRepository, + sender: WebhookSender, + private readonly findProjects: FindProjectsRepository, + private readonly findIssues: FindIssuesRepository, + ) { + this.dispatcher = new WebhookDispatcher(webhooks, sender); + } + + async handle(event: TrackerEvent) { + const name = webhookEventOf(event); + if (!name) return; + const targets = (await this.webhooks.findActive(event.projectId)).filter( + (w) => subscribes(w.events, name), + ); + if (!targets.length) return; + const project = await this.findProjects.findById(event.projectId); + const issueId = event.constructor.name.startsWith('Issue') + ? event.aggregateId + : ((event as { issueId?: string; comment?: { issueId: string } }) + .issueId ?? + (event as { comment?: { issueId: string } }).comment?.issueId ?? + null); + const issue = issueId ? await this.findIssues.findById(issueId) : null; + const payload = { + occurredAt: event.occurredAt, + actorId: event.actorId, + project: project + ? { id: project.id, key: project.key } + : { id: event.projectId }, + aggregateId: event.aggregateId, + version: event.aggregateVersion ?? null, + ...(issue + ? { issue: { id: issue.id, key: issue.key, title: issue.title } } + : {}), + data: dataOf(event), + }; + for (const webhook of targets) + void this.dispatcher + .deliver(webhook, name, payload) + .catch(() => undefined); + } +} + +export const WEBHOOK_HANDLERS = [ + CreateWebhookCommandHandler, + UpdateWebhookCommandHandler, + DeleteWebhookCommandHandler, + TestWebhookCommandHandler, + GetWebhooksQueryHandler, + OutgoingWebhooksEventHandler, +]; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/outgoing-webhooks.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/outgoing-webhooks.repository.ts new file mode 100644 index 0000000..e14097c --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/outgoing-webhooks.repository.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { + OutgoingWebhook, + OutgoingWebhooksRepository, + WebhookDelivery, +} from '@task-stream/api/tracker/application'; +import { Model } from 'mongoose'; +import { OutgoingWebhookDoc, ReadModels, fromDoc } from './read-model.schemas'; + +@Injectable() +export class MongoOutgoingWebhooksRepository extends OutgoingWebhooksRepository { + constructor( + @InjectModel(ReadModels.OutgoingWebhook) + private readonly webhooks: Model, + ) { + super(); + } + + async findByProject(projectId: string): Promise { + const docs = await this.webhooks + .find({ projectId }) + .sort({ createdAt: 1 }) + .lean(); + return docs.map((doc) => fromDoc(doc) as OutgoingWebhook); + } + + async findActive(projectId: string): Promise { + const docs = await this.webhooks + .find({ projectId, active: true }) + .lean(); + return docs.map((doc) => fromDoc(doc) as OutgoingWebhook); + } + + async findById(id: string): Promise { + const doc = await this.webhooks.findById(id).lean(); + return doc ? (fromDoc(doc) as OutgoingWebhook) : null; + } + + async insert({ id, ...rest }: OutgoingWebhook): Promise { + await this.webhooks.create({ _id: id, ...rest }); + } + + async update( + id: string, + changes: Partial>, + ): Promise { + if (Object.keys(changes).length) + await this.webhooks.updateOne({ _id: id }, { $set: changes }); + } + + async delete(id: string): Promise { + await this.webhooks.deleteOne({ _id: id }); + } + + async recordDelivery(id: string, delivery: WebhookDelivery): Promise { + await this.webhooks.updateOne( + { _id: id }, + { $set: { lastDelivery: delivery } }, + ); + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index 1a3a386..d4a1c3e 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -1,3 +1,4 @@ +import { OutgoingWebhook } from '@task-stream/api/tracker/application'; import { ActivityReadModel, CommentReadModel, @@ -30,6 +31,7 @@ export type ActivityDoc = Doc; export type TimelineDoc = Doc; export type NotificationDoc = Doc; export type SavedFilterDoc = Doc; +export type OutgoingWebhookDoc = Omit & { _id: string }; export interface GitIntegrationDoc { _id: string; secret: string; @@ -52,6 +54,7 @@ export const ReadModels = { SavedFilter: 'SavedFilter', Sprint: 'SprintReadModel', GitIntegration: 'GitIntegration', + OutgoingWebhook: 'OutgoingWebhook', } as const; const userRef = { _id: false, id: String, name: String }; @@ -203,6 +206,18 @@ export const GitIntegrationSchema = new Schema( { ...loose, collection: 'git_integrations' }, ); +export const OutgoingWebhookSchema = new Schema( + { + _id: String, + projectId: { type: String, index: true }, + active: Boolean, + events: [String], + createdBy: userRef, + createdAt: String, + }, + { ...loose, collection: 'outgoing_webhooks' }, +); + export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Project, schema: ProjectSchema }, { name: ReadModels.Issue, schema: IssueSchema }, @@ -210,6 +225,7 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Release, schema: ReleaseSchema }, { name: ReadModels.Sprint, schema: SprintSchema }, { name: ReadModels.GitIntegration, schema: GitIntegrationSchema }, + { name: ReadModels.OutgoingWebhook, schema: OutgoingWebhookSchema }, { name: ReadModels.Activity, schema: ActivitySchema }, { name: ReadModels.Timeline, schema: TimelineSchema }, { name: ReadModels.Notification, schema: NotificationSchema }, diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts index 3894d2f..9cda173 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts @@ -33,6 +33,7 @@ import { ReleaseDoc, SavedFilterDoc, GitIntegrationDoc, + OutgoingWebhookDoc, TimelineDoc, } from './read-model.schemas'; import { upsertIfNewer } from './upsert-if-newer'; @@ -52,6 +53,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized private readonly savedFilters: Model, @InjectModel(ReadModels.GitIntegration) private readonly gitIntegrations: Model, + @InjectModel(ReadModels.OutgoingWebhook) + private readonly outgoingWebhooks: Model, ) { super(); } @@ -64,6 +67,7 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized // Views of the board of a project that is gone. this.savedFilters.deleteMany({ projectId }), this.gitIntegrations.deleteOne({ _id: projectId }), + this.outgoingWebhooks.deleteMany({ projectId }), ]); } diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index 4e07ca3..cb71a7c 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -9,6 +9,8 @@ import { NotificationsRepository, SavedFiltersRepository, GitIntegrationsRepository, + OutgoingWebhooksRepository, + WebhookSender, FindActivityRepository, FindCommentsRepository, FindIssuesRepository, @@ -62,6 +64,8 @@ import { RedisChangeFeed } from './realtime/redis-change-feed'; import { MongoNotificationsRepository } from './mongo/notifications.repository'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; +import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; +import { HttpWebhookSender } from './webhooks/http-webhook.sender'; import { MongoFindSprintsRepository, MongoUpsertMaterializedSprintRepository, @@ -127,6 +131,11 @@ const PORTS = [ provide: GitIntegrationsRepository, useClass: MongoGitIntegrationsRepository, }, + { + provide: OutgoingWebhooksRepository, + useClass: MongoOutgoingWebhooksRepository, + }, + { provide: WebhookSender, useClass: HttpWebhookSender }, { provide: NotificationMailer, useClass: SmtpNotificationMailer }, // Realtime → Redis pub/sub { provide: ChangeFeed, useClass: RedisChangeFeed }, diff --git a/libs/api/tracker/infrastructure/src/lib/webhooks/http-webhook.sender.spec.ts b/libs/api/tracker/infrastructure/src/lib/webhooks/http-webhook.sender.spec.ts new file mode 100644 index 0000000..85df863 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/webhooks/http-webhook.sender.spec.ts @@ -0,0 +1,19 @@ +import { isPrivateAddress } from './http-webhook.sender'; + +describe('webhook sender', () => { + it('knows private and loopback addresses', () => { + for (const address of [ + '127.0.0.1', + '10.1.2.3', + '172.20.0.5', + '192.168.1.9', + '169.254.169.254', + '::1', + 'fd00::1', + '::ffff:127.0.0.1', + ]) + expect(isPrivateAddress(address)).toBe(true); + for (const address of ['8.8.8.8', '172.32.0.1', '2a00:1450::1']) + expect(isPrivateAddress(address)).toBe(false); + }); +}); diff --git a/libs/api/tracker/infrastructure/src/lib/webhooks/http-webhook.sender.ts b/libs/api/tracker/infrastructure/src/lib/webhooks/http-webhook.sender.ts new file mode 100644 index 0000000..0f29d81 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/webhooks/http-webhook.sender.ts @@ -0,0 +1,73 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { WebhookSender } from '@task-stream/api/tracker/application'; + +const TIMEOUT_MS = 5_000; + +/** Loopback, private, link-local and unique-local addresses. */ +export function isPrivateAddress(address: string): boolean { + if (isIP(address) === 4) { + const [a, b] = address.split('.').map(Number); + return ( + a === 10 || + a === 127 || + a === 0 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) + ); + } + const v6 = address.toLowerCase(); + return ( + v6 === '::1' || + v6 === '::' || + v6.startsWith('fe80:') || + v6.startsWith('fc') || + v6.startsWith('fd') || + (v6.startsWith('::ffff:') && isPrivateAddress(v6.slice(7))) + ); +} + +/** + * Posts deliveries with fetch, 5 s at most. Unless WEBHOOKS_ALLOW_PRIVATE is + * true, refuses hosts that resolve to this machine or its private network: + * a webhook must not become a way to reach internal services. + */ +@Injectable() +export class HttpWebhookSender extends WebhookSender { + private readonly allowPrivate: boolean; + + constructor(config: ConfigService) { + super(); + this.allowPrivate = + String(config.get('WEBHOOKS_ALLOW_PRIVATE') ?? '').toLowerCase() === + 'true'; + } + + async send(request: { + url: string; + body: string; + headers: Record; + }): Promise<{ status: number }> { + const url = new URL(request.url); + if (!this.allowPrivate) { + const host = url.hostname.replace(/^\[|\]$/g, ''); + const addresses = isIP(host) + ? [host] + : (await lookup(host, { all: true })).map((a) => a.address); + if (addresses.some(isPrivateAddress)) + throw new Error(`${url.hostname} is a private address`); + } + const response = await fetch(url, { + method: 'POST', + headers: request.headers, + body: request.body, + redirect: 'manual', + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + return { status: response.status }; + } +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 8e40c71..9748afe 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -275,6 +275,33 @@ export class SaveFilterInput { criteria!: SavedFilterCriteriaInput; } +@InputType() +export class CreateWebhookInput { + @Field(() => ID) @IsUUID() projectId!: string; + @Field() @IsUrl({ require_tld: false, require_protocol: true }) url!: string; + @Field(() => [String], { description: 'Event names, family.* or *' }) + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + events!: string[]; +} + +@InputType() +export class UpdateWebhookInput { + @Field(() => ID) @IsUUID() webhookId!: string; + @Field({ nullable: true }) + @IsOptional() + @IsUrl({ require_tld: false, require_protocol: true }) + url?: string; + @Field(() => [String], { nullable: true }) + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + events?: string[]; + @Field({ nullable: true }) @IsOptional() @IsBoolean() active?: boolean; +} + @InputType() export class RankIssueInput { @Field(() => ID) @IsUUID() issueId!: string; diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 6505e43..40a1a93 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -170,6 +170,42 @@ export class GitIntegrationGql { webhookPath!: string; } +@ObjectType('WebhookDelivery') +export class WebhookDeliveryGql { + @Field() at!: string; + @Field() event!: string; + @Field(() => Int, { nullable: true, description: 'HTTP status' }) + status!: number | null; + @Field() ok!: boolean; + @Field(() => String, { nullable: true }) error!: string | null; +} + +@ObjectType('Webhook', { + description: + 'Where the project tells another system what happened (signed POST, HMAC-SHA256)', +}) +export class WebhookGql { + @Field(() => ID) id!: string; + @Field(() => ID) projectId!: string; + @Field() url!: string; + @Field(() => [String], { description: 'Event names, family.* or *' }) + events!: string[]; + @Field() active!: boolean; + @Field(() => UserRefGql) createdBy!: UserRefGql; + @Field() createdAt!: string; + @Field(() => WebhookDeliveryGql, { nullable: true }) + lastDelivery!: WebhookDeliveryGql | null; +} + +@ObjectType('WebhookCreated') +export class WebhookCreatedGql { + @Field(() => ID) id!: string; + @Field({ + description: 'Signs every delivery. Shown only now: keep it.', + }) + secret!: string; +} + @ObjectType('IssueParent', { description: 'The ticket another one hangs from' }) export class IssueParentGql { @Field(() => ID) id!: string; diff --git a/libs/api/tracker/presenters/src/lib/graphql/webhook.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/webhook.resolver.ts new file mode 100644 index 0000000..1227840 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/graphql/webhook.resolver.ts @@ -0,0 +1,72 @@ +import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { User } from '@task-stream/api/identity/domain'; +import { CurrentUser } from '@task-stream/api/identity/presenters'; +import { WebhooksService } from '@task-stream/api/tracker/application'; +import { CreateWebhookInput, UpdateWebhookInput } from './inputs'; +import { WebhookCreatedGql, WebhookDeliveryGql, WebhookGql } from './types'; + +/** Outgoing webhooks of a project (owners and admins). */ +@Resolver(() => WebhookGql) +export class WebhookResolver { + constructor(private readonly webhooksService: WebhooksService) {} + + @Query(() => [WebhookGql]) + webhooks( + @CurrentUser() me: User, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + return this.webhooksService.list(me.id, projectId); + } + + @Query(() => [String], { + description: 'Event names a webhook can subscribe to (also family.* and *)', + }) + webhookEvents() { + return this.webhooksService.events(); + } + + @Mutation(() => WebhookCreatedGql) + createWebhook( + @CurrentUser() me: User, + @Args('input') input: CreateWebhookInput, + ) { + return this.webhooksService.create( + me.id, + input.projectId, + input.url, + input.events, + ); + } + + @Mutation(() => WebhookGql) + updateWebhook( + @CurrentUser() me: User, + @Args('input') input: UpdateWebhookInput, + ) { + const { webhookId, url, events, active } = input; + return this.webhooksService.update(me.id, webhookId, { + ...(url != null && { url }), + ...(events != null && { events }), + ...(active != null && { active }), + }); + } + + @Mutation(() => Boolean) + async deleteWebhook( + @CurrentUser() me: User, + @Args('webhookId', { type: () => ID }) webhookId: string, + ) { + await this.webhooksService.delete(me.id, webhookId); + return true; + } + + @Mutation(() => WebhookDeliveryGql, { + description: 'Send a ping now and tell how it went', + }) + testWebhook( + @CurrentUser() me: User, + @Args('webhookId', { type: () => ID }) webhookId: string, + ) { + return this.webhooksService.test(me.id, webhookId); + } +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 6612c78..4f3bc55 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -731,6 +731,29 @@ export class SaveFilterDto { criteria!: SavedFilterCriteriaDto; } +export class CreateWebhookDto { + @ApiProperty({ example: 'https://chat.example.org/hooks/taskstream' }) + @IsUrl({ require_tld: false, require_protocol: true }) + url!: string; + + @ApiProperty({ + type: [String], + example: ['issue.status_changed', 'release.*'], + description: 'Event names (GET /api/webhook-events), family.* or *', + }) + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + events!: string[]; +} + +export class UpdateWebhookDto extends PartialType(CreateWebhookDto) { + @ApiPropertyOptional({ description: 'Paused webhooks get nothing' }) + @IsOptional() + @IsBoolean() + active?: boolean; +} + export class RankIssueDto { @ApiPropertyOptional({ type: String, diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 724971d..462703f 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -167,6 +167,33 @@ export class GitIntegrationDto { @ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string; } +export class WebhookDeliveryDto { + @ApiProperty({ format: 'date-time' }) at!: string; + @ApiProperty({ example: 'issue.created' }) event!: string; + @ApiProperty({ type: Number, nullable: true, description: 'HTTP status' }) + status!: number | null; + @ApiProperty() ok!: boolean; + @ApiProperty({ type: String, nullable: true }) error!: string | null; +} + +export class WebhookDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ format: 'uuid' }) projectId!: string; + @ApiProperty() url!: string; + @ApiProperty({ type: [String] }) events!: string[]; + @ApiProperty() active!: boolean; + @ApiProperty({ type: UserRefDto }) createdBy!: UserRefDto; + @ApiProperty({ format: 'date-time' }) createdAt!: string; + @ApiProperty({ type: WebhookDeliveryDto, nullable: true }) + lastDelivery!: WebhookDeliveryDto | null; +} + +export class WebhookCreatedDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ description: 'Signs every delivery. Shown only now.' }) + secret!: string; +} + export class GitDeliveryResultDto { @ApiProperty({ description: 'Tickets that got something new' }) recorded!: number; diff --git a/libs/api/tracker/presenters/src/lib/http/webhooks.controller.ts b/libs/api/tracker/presenters/src/lib/http/webhooks.controller.ts new file mode 100644 index 0000000..7066572 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/webhooks.controller.ts @@ -0,0 +1,136 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, +} from '@nestjs/common'; +import { + ApiCreatedResponse, + ApiNoContentResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { User } from '@task-stream/api/identity/domain'; +import { + ApiPireonAuth, + CurrentUser, +} from '@task-stream/api/identity/presenters'; +import { + ApiCommandErrors, + ApiReadErrors, +} from '@task-stream/api/shared/presenters'; +import { WebhooksService } from '@task-stream/api/tracker/application'; +import { CreateWebhookDto, UpdateWebhookDto } from './dto/requests.dto'; +import { + WebhookCreatedDto, + WebhookDeliveryDto, + WebhookDto, +} from './dto/responses.dto'; +import { Refs } from './refs'; + +const WEBHOOK = ApiParam({ name: 'webhook', format: 'uuid' }); + +/** + * Outgoing webhooks: a signed POST to another system when something happens + * in the project. Header `X-TaskStream-Signature: sha256=`. + */ +@ApiTags('Webhooks') +@ApiPireonAuth() +@Controller() +export class WebhooksController { + constructor( + private readonly webhooks: WebhooksService, + private readonly refs: Refs, + ) {} + + @Get('webhook-events') + @ApiOperation({ summary: 'Event names a webhook can subscribe to' }) + @ApiOkResponse({ type: [String] }) + events() { + return this.webhooks.events(); + } + + @Get('projects/:project/webhooks') + @ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', + }) + @ApiOperation({ summary: "The project's webhooks (owners and admins)" }) + @ApiOkResponse({ type: [WebhookDto] }) + @ApiReadErrors() + async list(@CurrentUser() me: User, @Param('project') project: string) { + return this.webhooks.list(me.id, await this.refs.projectId(me.id, project)); + } + + @Post('projects/:project/webhooks') + @ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', + }) + @ApiOperation({ + summary: 'Add a webhook', + description: 'The answer carries the secret, the only time it is shown.', + }) + @ApiCreatedResponse({ type: WebhookCreatedDto }) + @ApiCommandErrors() + async create( + @CurrentUser() me: User, + @Param('project') project: string, + @Body() body: CreateWebhookDto, + ) { + return this.webhooks.create( + me.id, + await this.refs.projectId(me.id, project), + body.url, + body.events, + ); + } + + @Patch('outgoing-webhooks/:webhook') + @WEBHOOK + @ApiOperation({ summary: 'Change the URL or the events, pause or resume' }) + @ApiOkResponse({ type: WebhookDto }) + @ApiCommandErrors() + update( + @CurrentUser() me: User, + @Param('webhook', ParseUUIDPipe) webhook: string, + @Body() body: UpdateWebhookDto, + ) { + return this.webhooks.update(me.id, webhook, body); + } + + @Delete('outgoing-webhooks/:webhook') + @WEBHOOK + @HttpCode(204) + @ApiOperation({ summary: 'Delete a webhook' }) + @ApiNoContentResponse() + @ApiCommandErrors() + async delete( + @CurrentUser() me: User, + @Param('webhook', ParseUUIDPipe) webhook: string, + ) { + await this.webhooks.delete(me.id, webhook); + } + + @Post('outgoing-webhooks/:webhook/test') + @WEBHOOK + @HttpCode(200) + @ApiOperation({ summary: 'Send a ping now and tell how it went' }) + @ApiOkResponse({ type: WebhookDeliveryDto }) + @ApiCommandErrors() + test( + @CurrentUser() me: User, + @Param('webhook', ParseUUIDPipe) webhook: string, + ) { + return this.webhooks.test(me.id, webhook); + } +} diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index 10e471d..7d44eea 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -22,6 +22,8 @@ import { SprintResolver } from './graphql/sprint.resolver'; import { SprintsController } from './http/sprints.controller'; import { GitController } from './http/git.controller'; import { GitResolver } from './graphql/git.resolver'; +import { WebhookResolver } from './graphql/webhook.resolver'; +import { WebhooksController } from './http/webhooks.controller'; /** * Driving adapters of the tracker context: GraphQL (queries, mutations, @@ -54,6 +56,7 @@ import { GitResolver } from './graphql/git.resolver'; SavedFiltersController, SprintsController, GitController, + WebhooksController, ], providers: [ Refs, @@ -66,6 +69,7 @@ import { GitResolver } from './graphql/git.resolver'; SavedFilterResolver, SprintResolver, GitResolver, + WebhookResolver, ], }) export class TrackerPresentersModule { diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index a9ef1dd..34762a1 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -170,6 +170,27 @@ export interface DevLink { at: string; } +export interface WebhookDelivery { + at: string; + event: string; + /** HTTP status, null when it never answered. */ + status: number | null; + ok: boolean; + error: string | null; +} + +/** Where the project tells another system what happened. */ +export interface Webhook { + id: string; + url: string; + /** Event names, `family.*` or `*`. */ + events: string[]; + active: boolean; + createdBy: UserRef; + createdAt: string; + lastDelivery: WebhookDelivery | null; +} + /** The project's webhook for GitHub, Gitea or GitLab. */ export interface GitIntegration { enabled: boolean; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 80fab70..018bf6e 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -6,6 +6,8 @@ import { Comment, Epic, GitIntegration, + Webhook, + WebhookDelivery, SavedFilter, Sprint, SprintBurndown, @@ -131,6 +133,24 @@ export abstract class IssuesGateway { ): Observable; } +/** The project's outgoing webhooks (owners and admins). */ +export abstract class WebhooksGateway { + abstract list(projectId: string): Observable; + abstract events(): Observable; + /** The secret comes back only here. */ + abstract create( + projectId: string, + url: string, + events: string[], + ): Observable<{ id: string; secret: string }>; + abstract update( + webhookId: string, + changes: { url?: string; events?: string[]; active?: boolean }, + ): Observable; + abstract remove(webhookId: string): Observable; + abstract test(webhookId: string): Observable; +} + /** The project's Git webhook (owners and admins configure it). */ export abstract class GitGateway { abstract integration(projectId: string): Observable; diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-webhooks.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-webhooks.gateway.ts new file mode 100644 index 0000000..447503a --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-webhooks.gateway.ts @@ -0,0 +1,64 @@ +import { inject, Injectable } from '@angular/core'; +import { + Webhook, + WebhookDelivery, + WebhooksGateway, +} from '@task-stream/web/tracker/domain'; +import { map, Observable } from 'rxjs'; +import { + CreateWebhookDocument, + DeleteWebhookDocument, + TestWebhookDocument, + UpdateWebhookDocument, + WebhookEventsDocument, + WebhooksDocument, +} from '../graphql/generated'; +import { GraphqlOperations } from './graphql-operations'; + +@Injectable() +export class GraphqlWebhooksGateway extends WebhooksGateway { + private readonly gql = inject(GraphqlOperations); + + list(projectId: string): Observable { + return this.gql + .query(WebhooksDocument, { projectId }) + .pipe(map((d) => d.webhooks as Webhook[])); + } + + events(): Observable { + return this.gql + .query(WebhookEventsDocument, {}) + .pipe(map((d) => d.webhookEvents)); + } + + create( + projectId: string, + url: string, + events: string[], + ): Observable<{ id: string; secret: string }> { + return this.gql + .mutate(CreateWebhookDocument, { input: { projectId, url, events } }) + .pipe(map((d) => d.createWebhook)); + } + + update( + webhookId: string, + changes: { url?: string; events?: string[]; active?: boolean }, + ): Observable { + return this.gql + .mutate(UpdateWebhookDocument, { input: { webhookId, ...changes } }) + .pipe(map((d) => d.updateWebhook as Webhook)); + } + + remove(webhookId: string): Observable { + return this.gql + .mutate(DeleteWebhookDocument, { webhookId }) + .pipe(map(() => undefined)); + } + + test(webhookId: string): Observable { + return this.gql + .mutate(TestWebhookDocument, { webhookId }) + .pipe(map((d) => d.testWebhook)); + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 189c876..3037ad1 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -75,6 +75,13 @@ export type CreateSprintInput = { startDate: string; }; +export type CreateWebhookInput = { + /** Event names, family.* or * */ + events: Array; + projectId: string; + url: string; +}; + export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST'; export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN'; @@ -240,6 +247,13 @@ export type UpdateSprintInput = { startDate?: string | null | undefined; }; +export type UpdateWebhookInput = { + active?: boolean | null | undefined; + events?: Array | null | undefined; + url?: string | null | undefined; + webhookId: string; +}; + export type WorkflowStatusInput = { archived?: boolean; category: StatusCategory; @@ -1656,6 +1670,106 @@ export type MyProjectsChangedSubscription = { }; }; +export type WebhookFieldsFragment = { + id: string; + url: string; + events: Array; + active: boolean; + createdAt: string; + createdBy: { id: string; name: string }; + lastDelivery: { + at: string; + event: string; + status: number | null; + ok: boolean; + error: string | null; + } | null; +}; + +export type WebhookDeliveryFieldsFragment = { + at: string; + event: string; + status: number | null; + ok: boolean; + error: string | null; +}; + +export type WebhooksQueryVariables = Exact<{ + projectId: string; +}>; + +export type WebhooksQuery = { + webhooks: Array<{ + id: string; + url: string; + events: Array; + active: boolean; + createdAt: string; + createdBy: { id: string; name: string }; + lastDelivery: { + at: string; + event: string; + status: number | null; + ok: boolean; + error: string | null; + } | null; + }>; +}; + +export type WebhookEventsQueryVariables = Exact<{ [key: string]: never }>; + +export type WebhookEventsQuery = { webhookEvents: Array }; + +export type CreateWebhookMutationVariables = Exact<{ + input: CreateWebhookInput; +}>; + +export type CreateWebhookMutation = { + createWebhook: { id: string; secret: string }; +}; + +export type UpdateWebhookMutationVariables = Exact<{ + input: UpdateWebhookInput; +}>; + +export type UpdateWebhookMutation = { + updateWebhook: { + id: string; + url: string; + events: Array; + active: boolean; + createdAt: string; + createdBy: { id: string; name: string }; + lastDelivery: { + at: string; + event: string; + status: number | null; + ok: boolean; + error: string | null; + } | null; + }; +}; + +export type DeleteWebhookMutationVariables = Exact<{ + webhookId: string; +}>; + +export type DeleteWebhookMutation = { deleteWebhook: boolean }; + +export type TestWebhookMutationVariables = Exact<{ + webhookId: string; +}>; + +export type TestWebhookMutation = { + testWebhook: { + at: string; + event: string; + status: number | null; + ok: boolean; + error: string | null; + }; +}; + export const UserRefFieldsFragmentDoc = { kind: 'Document', definitions: [ @@ -2484,6 +2598,111 @@ export const SprintFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; +export const WebhookDeliveryFieldsFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'WebhookDeliveryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'WebhookDelivery' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + { kind: 'Field', name: { kind: 'Name', value: 'event' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'ok' } }, + { kind: 'Field', name: { kind: 'Name', value: 'error' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const WebhookFieldsFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'WebhookFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Webhook' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'url' } }, + { kind: 'Field', name: { kind: 'Name', value: 'events' } }, + { kind: 'Field', name: { kind: 'Name', value: 'active' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'createdBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'lastDelivery' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'WebhookDeliveryFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'WebhookDeliveryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'WebhookDelivery' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + { kind: 'Field', name: { kind: 'Name', value: 'event' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'ok' } }, + { kind: 'Field', name: { kind: 'Name', value: 'error' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const GitIntegrationDocument = { kind: 'Document', definitions: [ @@ -9995,3 +10214,450 @@ export const MyProjectsChangedDocument = { MyProjectsChangedSubscription, MyProjectsChangedSubscriptionVariables >; +export const WebhooksDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Webhooks' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'webhooks' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'WebhookFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'WebhookDeliveryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'WebhookDelivery' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + { kind: 'Field', name: { kind: 'Name', value: 'event' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'ok' } }, + { kind: 'Field', name: { kind: 'Name', value: 'error' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'WebhookFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Webhook' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'url' } }, + { kind: 'Field', name: { kind: 'Name', value: 'events' } }, + { kind: 'Field', name: { kind: 'Name', value: 'active' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'createdBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'lastDelivery' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'WebhookDeliveryFields' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const WebhookEventsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'WebhookEvents' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'webhookEvents' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const CreateWebhookDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'CreateWebhook' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CreateWebhookInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'createWebhook' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'input' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'secret' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + CreateWebhookMutation, + CreateWebhookMutationVariables +>; +export const UpdateWebhookDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'UpdateWebhook' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UpdateWebhookInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'updateWebhook' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'input' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'WebhookFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'WebhookDeliveryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'WebhookDelivery' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + { kind: 'Field', name: { kind: 'Name', value: 'event' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'ok' } }, + { kind: 'Field', name: { kind: 'Name', value: 'error' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'WebhookFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Webhook' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'url' } }, + { kind: 'Field', name: { kind: 'Name', value: 'events' } }, + { kind: 'Field', name: { kind: 'Name', value: 'active' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'createdBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'lastDelivery' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'WebhookDeliveryFields' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + UpdateWebhookMutation, + UpdateWebhookMutationVariables +>; +export const DeleteWebhookDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'DeleteWebhook' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'webhookId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'deleteWebhook' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'webhookId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'webhookId' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + DeleteWebhookMutation, + DeleteWebhookMutationVariables +>; +export const TestWebhookDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'TestWebhook' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'webhookId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'testWebhook' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'webhookId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'webhookId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'WebhookDeliveryFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'WebhookDeliveryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'WebhookDelivery' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + { kind: 'Field', name: { kind: 'Name', value: 'event' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'ok' } }, + { kind: 'Field', name: { kind: 'Name', value: 'error' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/webhooks.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/webhooks.graphql new file mode 100644 index 0000000..6bdd757 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/graphql/webhooks.graphql @@ -0,0 +1,54 @@ +fragment WebhookFields on Webhook { + id + url + events + active + createdAt + createdBy { + ...UserRefFields + } + lastDelivery { + ...WebhookDeliveryFields + } +} + +fragment WebhookDeliveryFields on WebhookDelivery { + at + event + status + ok + error +} + +query Webhooks($projectId: ID!) { + webhooks(projectId: $projectId) { + ...WebhookFields + } +} + +query WebhookEvents { + webhookEvents +} + +mutation CreateWebhook($input: CreateWebhookInput!) { + createWebhook(input: $input) { + id + secret + } +} + +mutation UpdateWebhook($input: UpdateWebhookInput!) { + updateWebhook(input: $input) { + ...WebhookFields + } +} + +mutation DeleteWebhook($webhookId: ID!) { + deleteWebhook(webhookId: $webhookId) +} + +mutation TestWebhook($webhookId: ID!) { + testWebhook(webhookId: $webhookId) { + ...WebhookDeliveryFields + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts index 33e3c37..10c362e 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -13,6 +13,7 @@ import { SavedFiltersGateway, SprintsGateway, GitGateway, + WebhooksGateway, TrackerRealtimeGateway, UsersGateway, } from '@task-stream/web/tracker/domain'; @@ -30,6 +31,7 @@ import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway'; import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway'; import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway'; import { GraphqlGitGateway } from './adapters/graphql-git.gateway'; +import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway'; /** Ports → GraphQL adapters (and REST for files). */ export function provideTrackerInfrastructure(): EnvironmentProviders { @@ -46,5 +48,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway }, { provide: SprintsGateway, useClass: GraphqlSprintsGateway }, { provide: GitGateway, useClass: GraphqlGitGateway }, + { provide: WebhooksGateway, useClass: GraphqlWebhooksGateway }, ]); } diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts index ed321d1..b33abe8 100644 --- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -17,6 +17,7 @@ import { import { NgTemplateOutlet } from '@angular/common'; import { PiIconComponent, PiNoteComponent } from '@pi/ui'; import { GitIntegrationComponent } from '../ui/git-integration'; +import { WebhooksComponent } from '../ui/webhooks'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { ProjectSettings, @@ -53,6 +54,7 @@ function idFrom(name: string): string { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GitIntegrationComponent, + WebhooksComponent, CdkDropList, CdkDrag, CdkDragHandle, @@ -468,6 +470,7 @@ function idFrom(name: string): string { [projectId]="project.id" [projectKey]="project.key" /> + } @if (facade.isOwner() && facade.project(); as project) { diff --git a/libs/web/tracker/presenters/src/lib/ui/webhooks.ts b/libs/web/tracker/presenters/src/lib/ui/webhooks.ts new file mode 100644 index 0000000..8ac17cb --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/webhooks.ts @@ -0,0 +1,338 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + effect, + inject, + input, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { PiActionButtonComponent, PiTextFieldComponent } from '@pi/ui'; +import { ToastService } from '@task-stream/web/shared/ui'; +import { Webhook, WebhooksGateway } from '@task-stream/web/tracker/domain'; +import { timeAgo } from './copy'; + +/** What each event means, in the words of the settings page. */ +const EVENT_LABEL: Record = { + 'issue.created': 'Ticket creado', + 'issue.updated': 'Ticket editado', + 'issue.status_changed': 'Cambio de estado', + 'issue.assigned': 'Asignación', + 'issue.sprint_changed': 'Entra o sale de un sprint', + 'issue.release_changed': 'Entra o sale de una release', + 'issue.dev_link': 'Commit, rama o PR', + 'issue.deleted': 'Ticket borrado', + 'comment.added': 'Comentario', + 'comment.edited': 'Comentario editado', + 'comment.deleted': 'Comentario borrado', + 'release.created': 'Release creada', + 'release.shipped': 'Release publicada', + 'release.archived': 'Release archivada', + 'sprint.started': 'Sprint empezado', + 'sprint.closed': 'Sprint cerrado', +}; + +/** Settings card: signed POSTs to other systems when things happen. */ +@Component({ + selector: 'ts-webhooks', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PiActionButtonComponent, PiTextFieldComponent], + template: ` +
+
+

Webhooks

+

+ Avisa a otro sistema (un chat, un CI, un script) de lo que pasa en el + proyecto: un POST en JSON firmado con + X-TaskStream-Signature: sha256=… (HMAC del cuerpo con el + secreto). +

+
+ + @if (secret(); as shown) { +

+ Secreto del webhook nuevo (no se volverá a mostrar): + {{ shown }} + +

+ } + + @for (hook of hooks(); track hook.id) { +
+
+ {{ hook.url }} + @if (!hook.active) { + En pausa + } +
+

+ {{ eventsLabel(hook.events) }} +

+ @if (hook.lastDelivery; as last) { +

+ Último envío ({{ last.event }}) {{ ago(last.at) }}: + {{ last.ok ? 'OK ' + last.status : (last.error ?? 'falló') }} +

+ } +
+ + + +
+
+ } + +
+ +
+ Eventos + + @for (event of events(); track event) { + + } +
+
+ +
+ +
+ `, + styles: ` + .ts-hooks { + display: grid; + gap: 0.75rem; + h3 { + margin: 0 0 0.25rem; + } + } + .ts-hooks__secret { + margin: 0; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + background: color-mix(in srgb, var(--ts-brand) 10%, transparent); + code { + overflow-wrap: anywhere; + } + } + .ts-hooks__item { + display: grid; + gap: 0.25rem; + padding: 0.625rem 0; + border-top: 1px solid var(--ts-line); + &.is-paused { + opacity: 0.7; + } + p { + margin: 0; + } + } + .ts-hooks__head { + display: flex; + gap: 0.5rem; + align-items: center; + min-width: 0; + } + .ts-hooks__url { + overflow-wrap: anywhere; + } + .ts-hooks__events, + .ts-hooks__last { + font-size: 0.8125rem; + } + .ts-hooks__last.is-bad { + color: var(--ts-c-red, var(--ts-ink)); + } + .ts-hooks__actions { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + } + .ts-hooks__new { + display: grid; + gap: 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid var(--ts-line); + } + .ts-hooks__picks { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); + gap: 0.25rem 1rem; + margin: 0; + padding: 0; + border: 0; + font-size: 0.875rem; + legend { + margin-bottom: 0.25rem; + color: var(--ts-muted); + } + label { + display: flex; + gap: 0.375rem; + align-items: center; + } + } + `, +}) +export class WebhooksComponent { + readonly projectId = input.required(); + + private readonly gateway = inject(WebhooksGateway); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + protected readonly hooks = signal([]); + protected readonly events = signal([]); + protected readonly url = signal(''); + protected readonly picked = signal(['issue.status_changed']); + protected readonly secret = signal(null); + protected readonly ago = timeAgo; + + constructor() { + this.gateway + .events() + .pipe(takeUntilDestroyed()) + .subscribe((events) => this.events.set(events)); + effect(() => { + const projectId = this.projectId(); + untracked(() => this.reload(projectId)); + }); + } + + protected label = (event: string) => EVENT_LABEL[event] ?? event; + protected eventsLabel(events: string[]): string { + return events.includes('*') + ? 'Todos los eventos' + : events.map((e) => this.label(e)).join(' · '); + } + + protected pick(event: string, on: boolean): void { + this.picked.update((list) => + on ? [...new Set([...list, event])] : list.filter((e) => e !== event), + ); + } + + protected create(): void { + const url = this.url().trim(); + if (!url || !this.picked().length) return; + const events = this.picked().includes('*') ? ['*'] : this.picked(); + this.gateway + .create(this.projectId(), url, events) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: ({ secret }) => { + this.secret.set(secret); + this.url.set(''); + this.reload(this.projectId()); + }, + error: (error) => this.fail(error, 'No se pudo añadir'), + }); + } + + protected toggle(hook: Webhook): void { + this.gateway + .update(hook.id, { active: !hook.active }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => this.reload(this.projectId()), + error: (error) => this.fail(error, 'No se pudo cambiar'), + }); + } + + protected remove(hook: Webhook): void { + if (!confirm(`¿Borrar el webhook a ${hook.url}?`)) return; + this.gateway + .remove(hook.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => this.reload(this.projectId()), + error: (error) => this.fail(error, 'No se pudo borrar'), + }); + } + + protected test(hook: Webhook): void { + this.gateway + .test(hook.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (delivery) => { + if (delivery.ok) + this.toast.success(`Ping entregado (${delivery.status})`); + else + this.toast.error( + `El ping falló: ${delivery.error ?? 'sin respuesta'}`, + ); + this.reload(this.projectId()); + }, + error: (error) => this.fail(error, 'No se pudo probar'), + }); + } + + protected copy(text: string): void { + navigator.clipboard + ?.writeText(text) + .then(() => this.toast.success('Copiado')) + .catch(() => this.toast.error('No se pudo copiar')); + } + + private reload(projectId: string): void { + this.gateway + .list(projectId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (hooks) => this.hooks.set(hooks), + error: (error) => this.fail(error, 'No se pudieron leer los webhooks'), + }); + } + + private fail(error: unknown, fallback: string): void { + const message = + (error as { message?: string })?.message?.split('\n')[0] ?? fallback; + this.toast.error(message || fallback); + } +}