diff --git a/.env.example b/.env.example index a182a1c..810f41c 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,11 @@ OPENAPI_FILE=apps/api/openapi.json ATTACHMENTS_DIR=data/attachments ATTACHMENTS_MAX_MB=25 +# Optional e-mail copies of notifications. Without SMTP_URL nothing is sent. +SMTP_URL= +MAIL_FROM= +WEB_URL=http://localhost:4200 + # 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 59e1a7c..6273c9b 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -4707,6 +4707,363 @@ ], "security": [] } + }, + "/api/me/notifications": { + "get": { + "operationId": "NotificationsController_mine", + "parameters": [ + { + "name": "unreadOnly", + "required": false, + "in": "query", + "description": "Only unread ones", + "schema": { + "default": false, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 30, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyNotificationsDto" + } + } + } + }, + "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": "My notifications, newest first", + "tags": [ + "Users" + ] + } + }, + "/api/me/notifications/read": { + "post": { + "operationId": "NotificationsController_markRead", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkNotificationsReadDto" + } + } + } + }, + "responses": { + "200": { + "description": "Done" + }, + "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": "Mark notifications as read (all of them without `ids`)", + "tags": [ + "Users" + ] + } + }, + "/api/me/notification-settings": { + "get": { + "operationId": "NotificationsController_settings", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSettingsResponseDto" + } + } + } + }, + "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": "Whether I get copies by e-mail", + "tags": [ + "Users" + ] + }, + "put": { + "operationId": "NotificationsController_updateSettings", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSettingsDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSettingsResponseDto" + } + } + } + }, + "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": "Turn e-mail copies on or off", + "tags": [ + "Users" + ] + } } }, "info": { @@ -6714,6 +7071,145 @@ "description": "Publish even with unfinished tickets" } } + }, + "Object": { + "type": "object", + "properties": {} + }, + "NotificationKind": { + "type": "string", + "enum": [ + "ASSIGNED", + "STATUS_CHANGED", + "QA_FAILED", + "COMMENTED", + "MENTIONED", + "WATCHED", + "ADDED_TO_PROJECT", + "REMINDER" + ] + }, + "NotificationDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationKind" + } + ] + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "projectKey": { + "type": "string", + "example": "WEB" + }, + "issueId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "issueKey": { + "type": "string", + "nullable": true, + "example": "WEB-12" + }, + "actor": { + "$ref": "#/components/schemas/UserRefDto" + }, + "summary": { + "type": "string", + "example": "Ana te asignó WEB-12: Login roto en Safari" + }, + "readAt": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Null: unread" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "kind", + "projectId", + "projectKey", + "issueId", + "issueKey", + "actor", + "summary", + "readAt", + "createdAt" + ] + }, + "MyNotificationsDto": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationDto" + } + }, + "unread": { + "type": "number", + "description": "All my unread ones, not just these" + } + }, + "required": [ + "items", + "unread" + ] + }, + "MarkNotificationsReadDto": { + "type": "object", + "properties": { + "ids": { + "description": "Omit to mark all of them", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "NotificationSettingsResponseDto": { + "type": "object", + "properties": { + "email": { + "type": "boolean", + "description": "Copies by e-mail" + }, + "emailAvailable": { + "type": "boolean", + "description": "Whether this installation can send e-mail" + } + }, + "required": [ + "email", + "emailAvailable" + ] + }, + "NotificationSettingsDto": { + "type": "object", + "properties": { + "email": { + "type": "boolean", + "description": "Copies by e-mail" + } + }, + "required": [ + "email" + ] } } } diff --git a/apps/api/schema.gql b/apps/api/schema.gql index c801359..b6d597e 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -342,6 +342,9 @@ type Mutation { """Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE""" linkIssues(input: LinkIssuesInput!): CommandResult! + """Mark these as read, or all of them when `ids` is omitted""" + markNotificationsRead(ids: [ID!]): Boolean! + """ Ship the release. Fails while it has unfinished tickets unless force = true. """ @@ -357,6 +360,7 @@ type Mutation { setIssueRelease(issueId: ID!, releaseId: ID): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! updateIssue(input: UpdateIssueInput!): CommandResult! + updateNotificationSettings(email: Boolean!): NotificationSettings! updateProject(input: UpdateProjectInput!): CommandResult! """ @@ -366,6 +370,48 @@ type Mutation { updateRelease(input: UpdateReleaseInput!): CommandResult! } +type MyNotifications { + items: [Notification!]! + + """All my unread ones, not just these""" + unread: Int! +} + +type Notification { + actor: UserRef! + createdAt: String! + id: ID! + issueId: ID + issueKey: String + kind: NotificationKind! + projectId: ID! + projectKey: String! + + """Null: unread""" + readAt: String + summary: String! +} + +"""Why someone is told""" +enum NotificationKind { + ADDED_TO_PROJECT + ASSIGNED + COMMENTED + MENTIONED + QA_FAILED + REMINDER + STATUS_CHANGED + WATCHED +} + +type NotificationSettings { + """Copies by e-mail""" + email: Boolean! + + """Whether this installation can send e-mail at all""" + emailAvailable: Boolean! +} + type Project { """ Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA @@ -498,8 +544,12 @@ type Query { """ myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage! + """My notifications, newest first, and how many are unread""" + myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications! + """Projects where I am a member""" myProjects: [Project!]! + notificationSettings: NotificationSettings! """A project of mine, or any public one (no sign-in needed)""" project( @@ -603,6 +653,9 @@ type Subscription { """Projects I am added to / removed from""" myProjectsChanged: ProjectChange! + + """My notifications, as they happen""" + notificationAdded: Notification! projectChanged(projectId: ID!): ProjectChange! releaseChanged(projectId: ID!): ReleaseChange! } diff --git a/apps/api/src/app/config.schema.ts b/apps/api/src/app/config.schema.ts index ebc73b3..698b870 100644 --- a/apps/api/src/app/config.schema.ts +++ b/apps/api/src/app/config.schema.ts @@ -46,4 +46,10 @@ export const configSchema = Joi.object({ // Attached files: where they are kept (a volume in Docker) and how big one can be. ATTACHMENTS_DIR: Joi.string().default('data/attachments'), ATTACHMENTS_MAX_MB: Joi.number().min(1).max(500).default(25), + + // E-mail copies of notifications (optional): without SMTP_URL nothing is sent. + SMTP_URL: Joi.string().allow('').optional(), + MAIL_FROM: Joi.string().allow('').optional(), + // Public address of the web app, for links in e-mails. + WEB_URL: Joi.string().allow('').optional(), }); diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 8c305ef..4e23f5c 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -703,6 +703,58 @@ export interface paths { patch?: never; trace?: never; }; + "/api/me/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** My notifications, newest first */ + get: operations["NotificationsController_mine"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/me/notifications/read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark notifications as read (all of them without `ids`) */ + post: operations["NotificationsController_markRead"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/me/notification-settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Whether I get copies by e-mail */ + get: operations["NotificationsController_settings"]; + /** Turn e-mail copies on or off */ + put: operations["NotificationsController_updateSettings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1336,6 +1388,50 @@ export interface components { */ force: boolean; }; + Object: Record; + /** @enum {string} */ + NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER"; + NotificationDto: { + id: string; + kind: components["schemas"]["NotificationKind"]; + /** Format: uuid */ + projectId: string; + /** @example WEB */ + projectKey: string; + /** Format: uuid */ + issueId: string | null; + /** @example WEB-12 */ + issueKey: string | null; + actor: components["schemas"]["UserRefDto"]; + /** @example Ana te asignó WEB-12: Login roto en Safari */ + summary: string; + /** + * Format: date-time + * @description Null: unread + */ + readAt: string | null; + /** Format: date-time */ + createdAt: string; + }; + MyNotificationsDto: { + items: components["schemas"]["NotificationDto"][]; + /** @description All my unread ones, not just these */ + unread: number; + }; + MarkNotificationsReadDto: { + /** @description Omit to mark all of them */ + ids?: string[]; + }; + NotificationSettingsResponseDto: { + /** @description Copies by e-mail */ + email: boolean; + /** @description Whether this installation can send e-mail */ + emailAvailable: boolean; + }; + NotificationSettingsDto: { + /** @description Copies by e-mail */ + email: boolean; + }; }; responses: never; parameters: never; @@ -4543,4 +4639,271 @@ export interface operations { }; }; }; + NotificationsController_mine: { + parameters: { + query?: { + /** @description Only unread ones */ + unreadOnly?: components["schemas"]["Object"]; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MyNotificationsDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + NotificationsController_markRead: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MarkNotificationsReadDto"]; + }; + }; + responses: { + /** @description Done */ + 200: { + 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"]; + }; + }; + }; + }; + NotificationsController_settings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NotificationSettingsResponseDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + NotificationsController_updateSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NotificationSettingsDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NotificationSettingsResponseDto"]; + }; + }; + /** @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/apps/web/src/app/app.ts b/apps/web/src/app/app.ts index 4f35f92..298c188 100644 --- a/apps/web/src/app/app.ts +++ b/apps/web/src/app/app.ts @@ -11,6 +11,7 @@ import { filter, map } from 'rxjs'; import { AuthFacade } from '@task-stream/web/identity/application'; import { UserMenuComponent } from '@task-stream/web/identity/presenters'; import { AppShellComponent } from '@task-stream/web/shared/ui'; +import { NotificationsBell } from '@task-stream/web/tracker/presenters'; @Component({ selector: 'ts-root', @@ -21,6 +22,7 @@ import { AppShellComponent } from '@task-stream/web/shared/ui'; RouterLinkActive, AppShellComponent, UserMenuComponent, + NotificationsBell, ], template: ` @@ -44,6 +46,7 @@ import { AppShellComponent } from '@task-stream/web/shared/ui'; Explorar @if (auth.isAuthenticated()) { + } @else if (auth.settled() && !onLogin()) { ::`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription +`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado +por defecto); un correo que falla se registra y no bloquea el stream. + ### Eventos de dominio Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`, @@ -215,6 +233,7 @@ Todas devuelven `CommandResult { id, key, version }`. | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `removeAttachment` (subir: por REST) | | Comentarios | `addComment`, `editComment`, `deleteComment` | +| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | ### Subscriptions @@ -417,6 +436,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}` | +| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | | 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}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index 05aef6d..208d8f7 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -70,31 +70,34 @@ 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` | -| `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) | La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`). diff --git a/docs/08-decisiones.md b/docs/08-decisiones.md index f6e8a79..75dbf2b 100644 --- a/docs/08-decisiones.md +++ b/docs/08-decisiones.md @@ -71,3 +71,16 @@ presentación se prueba sin plantillas. **Coste**: una clase más por store, cas El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa `apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución. + +### Adjuntos en disco, notificaciones fuera del event store + +Los **adjuntos** se registran como eventos del ticket, pero los bytes van a un puerto `AttachmentStorage` (disco, +`ATTACHMENTS_DIR`): un event store no es sitio para megabytes. Se escribe el archivo antes que el evento, y si el +evento falla se borra el archivo, así que nunca hay un adjunto apuntando a nada. **Coste**: el volumen hay que +copiarlo y compartirlo entre réplicas. + +Las **notificaciones** sí salen de eventos, pero no son eventos: si un aviso está leído, o si alguien quiere copia por +correo, solo le importa a esa persona y no necesita historia. Se guardan en MongoDB por destinatario y se marcan como +leídas sin pasar por un agregado. Si se reconstruye el read model, se pueden regenerar desde los eventos (quedarían +como no leídas). **Alternativa descartada**: un agregado «bandeja» por persona, con eventos `NotificationRead`; mucha +ceremonia para un estado que nadie audita. diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 867450b..4ed4fb1 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -7,4 +7,6 @@ export * from './lib/comments.service'; export * from './lib/releases.service'; export * from './lib/changes.service'; export * from './lib/workflows.service'; +export * from './lib/notifications.service'; +export * from './lib/notifications'; export * from './lib/tracker-application.module'; diff --git a/libs/api/tracker/application/src/lib/notifications.service.ts b/libs/api/tracker/application/src/lib/notifications.service.ts new file mode 100644 index 0000000..b72dcc6 --- /dev/null +++ b/libs/api/tracker/application/src/lib/notifications.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { NotificationReadModel } from '@task-stream/api/tracker/domain'; +import { + MarkNotificationsReadCommand, + UpdateNotificationSettingsCommand, +} from './notifications/notifications.commands'; +import { + GetMyNotificationSettingsQuery, + GetMyNotificationsQuery, + MyNotificationSettings, + MyNotifications, +} from './notifications/notifications.queries'; +import { ChangeFeed } from './ports/change-feed'; + +/** The signed-in person's notifications: always their own, nobody else's. */ +@Injectable() +export class NotificationsService { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + private readonly changeFeed: ChangeFeed, + ) {} + + mine( + userId: string, + options: { unreadOnly?: boolean; limit?: number } = {}, + ): Promise { + return this.queryBus.execute( + new GetMyNotificationsQuery( + userId, + options.unreadOnly ?? false, + options.limit ?? 30, + ), + ); + } + + /** `ids` omitted: everything. */ + markRead(userId: string, ids?: string[] | null): Promise { + return this.commandBus.execute( + new MarkNotificationsReadCommand(userId, ids?.length ? ids : null), + ); + } + + settings(userId: string): Promise { + return this.queryBus.execute(new GetMyNotificationSettingsQuery(userId)); + } + + async updateSettings( + userId: string, + changes: { email: boolean }, + ): Promise { + await this.commandBus.execute( + new UpdateNotificationSettingsCommand(userId, changes.email), + ); + return this.settings(userId); + } + + /** New notifications for this person, as they happen. */ + watch(userId: string): AsyncIterableIterator { + return this.changeFeed.notifications(userId); + } +} diff --git a/libs/api/tracker/application/src/lib/notifications/index.ts b/libs/api/tracker/application/src/lib/notifications/index.ts new file mode 100644 index 0000000..4ec238a --- /dev/null +++ b/libs/api/tracker/application/src/lib/notifications/index.ts @@ -0,0 +1,3 @@ +export * from './notifier'; +export * from './notifications.queries'; +export * from './notifications.commands'; diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.commands.ts b/libs/api/tracker/application/src/lib/notifications/notifications.commands.ts new file mode 100644 index 0000000..b2fe064 --- /dev/null +++ b/libs/api/tracker/application/src/lib/notifications/notifications.commands.ts @@ -0,0 +1,51 @@ +import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { NotificationsRepository } from '../ports/notifications.repository'; + +/* + * Per-person state, outside the event store: whether a notification was read + * and whether to copy them by e-mail concern nobody else and need no history. + */ + +export class MarkNotificationsReadCommand extends Command { + constructor( + public readonly userId: string, + /** Null: all of them. */ + public readonly ids: string[] | null, + ) { + super(); + } +} + +export class UpdateNotificationSettingsCommand extends Command { + constructor( + public readonly userId: string, + public readonly email: boolean, + ) { + super(); + } +} + +@CommandHandler(MarkNotificationsReadCommand) +export class MarkNotificationsReadCommandHandler + implements ICommandHandler +{ + constructor(private readonly notifications: NotificationsRepository) {} + + async execute(command: MarkNotificationsReadCommand): Promise { + await this.notifications.markRead(command.userId, command.ids); + } +} + +@CommandHandler(UpdateNotificationSettingsCommand) +export class UpdateNotificationSettingsCommandHandler + implements ICommandHandler +{ + constructor(private readonly notifications: NotificationsRepository) {} + + async execute(command: UpdateNotificationSettingsCommand): Promise { + await this.notifications.saveSettings({ + userId: command.userId, + email: command.email, + }); + } +} diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts new file mode 100644 index 0000000..9f1c708 --- /dev/null +++ b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts @@ -0,0 +1,143 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + CommentAddedEvent, + IssueAssignedEvent, + IssueCreatedEvent, + IssueStatusChangedEvent, + NotificationKind, + ProjectMemberAddedEvent, + StatusCategory, + TrackerEvent, +} from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { UserDirectory } from '../ports/user-directory'; +import { NotificationDraft, Notifier } from './notifier'; + +/* Read by people, like the activity feed: Spanish. */ + +const excerpt = (text: string, max = 120) => { + const flat = text.replace(/\s+/g, ' ').trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +}; + +/** + * Who is told what, for the events that concern someone in particular: + * whoever a ticket is assigned to, whoever reported or works on it when it + * moves or gets a comment, whoever joins a project. + */ +@EventsHandler( + IssueCreatedEvent, + IssueAssignedEvent, + IssueStatusChangedEvent, + CommentAddedEvent, + ProjectMemberAddedEvent, +) +export class NotificationsEventHandler implements IEventHandler { + constructor( + private readonly notifier: Notifier, + private readonly findIssues: FindIssuesRepository, + private readonly findProjects: FindProjectsRepository, + private readonly users: UserDirectory, + ) {} + + async handle(event: TrackerEvent): Promise { + const project = await this.findProjects.findById(event.projectId); + if (!project) return; + const actor = (await this.users.refs([event.actorId])).of( + event.actorId, + ).name; + + if (event instanceof ProjectMemberAddedEvent) { + return this.notifier.deliver( + event, + { + projectId: project.id, + projectKey: project.key, + issueId: null, + issueKey: null, + }, + [ + { + userId: event.userId, + kind: NotificationKind.ADDED_TO_PROJECT, + summary: `${actor} te añadió al proyecto ${project.name} (${project.key})`, + }, + ], + ); + } + + const issueId = + event instanceof CommentAddedEvent + ? event.comment.issueId + : event.aggregateId; + const issue = + event instanceof IssueCreatedEvent + ? event.issue + : await this.findIssues.findById(issueId); + if (!issue) return; + const reporterId = + 'reporterId' in issue ? issue.reporterId : issue.reporter.id; + const assigneeId = + 'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null); + const ticket = `${issue.key}: ${issue.title}`; + + const drafts: NotificationDraft[] = []; + if (event instanceof IssueCreatedEvent && assigneeId) { + drafts.push({ + userId: assigneeId, + kind: NotificationKind.ASSIGNED, + summary: `${actor} te asignó ${ticket}`, + }); + } + if (event instanceof IssueAssignedEvent && event.assigneeId) { + drafts.push({ + userId: event.assigneeId, + kind: NotificationKind.ASSIGNED, + summary: `${actor} te asignó ${ticket}`, + }); + } + if (event instanceof IssueStatusChangedEvent) { + const to = event.meta?.toName ?? event.to; + // Back from QA without passing: the one move whoever did the work must hear about. + const failedQa = + event.meta?.fromCategory === StatusCategory.QA && + event.meta?.toCategory !== StatusCategory.QA && + event.meta?.toCategory !== StatusCategory.DONE; + const reason = event.reason ? `: ${excerpt(event.reason)}` : ''; + for (const userId of [assigneeId, reporterId]) { + if (!userId) continue; + drafts.push({ + userId, + kind: failedQa + ? NotificationKind.QA_FAILED + : NotificationKind.STATUS_CHANGED, + summary: failedQa + ? `${actor} devolvió ${ticket} desde QA${reason}` + : `${actor} movió ${ticket} a ${to}${reason}`, + }); + } + } + if (event instanceof CommentAddedEvent) { + for (const userId of [assigneeId, reporterId]) { + if (!userId) continue; + drafts.push({ + userId, + kind: NotificationKind.COMMENTED, + summary: `${actor} comentó en ${issue.key}: «${excerpt(event.comment.body)}»`, + }); + } + } + + await this.notifier.deliver( + event, + { + projectId: project.id, + projectKey: project.key, + issueId: issue.id, + issueKey: issue.key, + }, + drafts, + ); + } +} diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.queries.ts b/libs/api/tracker/application/src/lib/notifications/notifications.queries.ts new file mode 100644 index 0000000..27de344 --- /dev/null +++ b/libs/api/tracker/application/src/lib/notifications/notifications.queries.ts @@ -0,0 +1,71 @@ +import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs'; +import { NotificationReadModel } from '@task-stream/api/tracker/domain'; +import { NotificationMailer } from '../ports/notification-mailer'; +import { NotificationsRepository } from '../ports/notifications.repository'; + +export interface MyNotifications { + items: NotificationReadModel[]; + unread: number; +} + +export interface MyNotificationSettings { + /** Copies by e-mail (the user's choice). */ + email: boolean; + /** Whether this installation can send e-mail at all. */ + emailAvailable: boolean; +} + +export class GetMyNotificationsQuery extends Query { + constructor( + public readonly userId: string, + public readonly unreadOnly: boolean, + public readonly limit: number, + ) { + super(); + } +} + +export class GetMyNotificationSettingsQuery extends Query { + constructor(public readonly userId: string) { + super(); + } +} + +@QueryHandler(GetMyNotificationsQuery) +export class GetMyNotificationsQueryHandler + implements IQueryHandler +{ + constructor(private readonly notifications: NotificationsRepository) {} + + async execute(query: GetMyNotificationsQuery): Promise { + const [items, unread] = await Promise.all([ + this.notifications.findForUser(query.userId, { + unreadOnly: query.unreadOnly, + limit: Math.min(Math.max(query.limit, 1), 100), + }), + this.notifications.countUnread(query.userId), + ]); + return { items, unread }; + } +} + +@QueryHandler(GetMyNotificationSettingsQuery) +export class GetMyNotificationSettingsQueryHandler + implements + IQueryHandler +{ + constructor( + private readonly notifications: NotificationsRepository, + private readonly mailer: NotificationMailer, + ) {} + + async execute( + query: GetMyNotificationSettingsQuery, + ): Promise { + const settings = await this.notifications.settings(query.userId); + return { + email: this.mailer.enabled && settings.email, + emailAvailable: this.mailer.enabled, + }; + } +} diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts new file mode 100644 index 0000000..8fb1eef --- /dev/null +++ b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts @@ -0,0 +1,176 @@ +import { + CommentAddedEvent, + IssueAssignedEvent, + IssueStatusChangedEvent, + NotificationKind, + NotificationReadModel, + NotificationSettingsReadModel, + ProjectMemberAddedEvent, + ProjectRole, + StatusCategory, + TrackerEvent, +} from '@task-stream/api/tracker/domain'; +import { FakeUserDirectory } from '../../testing/in-memory'; +import { ChangeFeed } from '../ports/change-feed'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { NotificationMailer } from '../ports/notification-mailer'; +import { NotificationsRepository } from '../ports/notifications.repository'; +import { NotificationsEventHandler } from './notifications.event-handler'; +import { Notifier } from './notifier'; + +class InMemoryNotifications extends NotificationsRepository { + readonly all: NotificationReadModel[] = []; + readonly emailOn = new Set(); + async insert(n: NotificationReadModel) { + if (this.all.some((x) => x.id === n.id)) return false; + this.all.push(n); + return true; + } + async findForUser(userId: string) { + return this.all.filter((n) => n.userId === userId); + } + async countUnread(userId: string) { + return this.all.filter((n) => n.userId === userId && !n.readAt).length; + } + async markRead() { + return; + } + async settings(userId: string): Promise { + return { userId, email: this.emailOn.has(userId) }; + } + async saveSettings() { + return; + } +} + +const at = (event: T, version = 2): T => + Object.assign(event, { + aggregateVersion: version, + occurredAt: '2026-10-08T10:00:00.000Z', + }); + +function setup(mailEnabled = false) { + const repo = new InMemoryNotifications(); + const published: NotificationReadModel[] = []; + const mailed: string[] = []; + const users = new FakeUserDirectory().add('ana').add('bob').add('qa'); + const issue = { + id: 'i-1', + key: 'WEB-1', + title: 'Login roto', + reporter: { id: 'qa', name: 'QA' }, + assignee: { id: 'bob', name: 'Bob' }, + }; + const notifier = new Notifier( + repo, + { + publishNotification: async (n: NotificationReadModel) => { + published.push(n); + }, + } as unknown as ChangeFeed, + { + enabled: mailEnabled, + send: async (to: { email: string }) => { + mailed.push(to.email); + }, + } as unknown as NotificationMailer, + users, + ); + const handler = new NotificationsEventHandler( + notifier, + { findById: async () => issue } as unknown as FindIssuesRepository, + { + findById: async () => ({ id: 'p-1', key: 'WEB', name: 'Web' }), + } as unknown as FindProjectsRepository, + users, + ); + return { repo, published, mailed, handler }; +} + +describe('notifications', () => { + it('tells whoever a ticket is assigned to, not whoever assigned it', async () => { + const { handler, repo, published } = setup(); + await handler.handle( + at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null)), + ); + await handler.handle( + at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'ana', null), 3), + ); + + expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([ + ['bob', NotificationKind.ASSIGNED], + ]); + expect(published).toHaveLength(1); + expect(repo.all[0].summary).toContain('WEB-1'); + }); + + it('marks a ticket sent back from QA as such, for whoever works on it and reported it', async () => { + const { handler, repo } = setup(); + await handler.handle( + at( + new IssueStatusChangedEvent( + 'ana', + 'i-1', + 'p-1', + 'IN_QA', + 'QA_FAILED', + 'Falla en Safari', + { + fromName: 'En QA', + toName: 'QA fallido', + fromCategory: StatusCategory.QA, + toCategory: StatusCategory.IN_PROGRESS, + }, + ), + ), + ); + + expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([ + ['bob', NotificationKind.QA_FAILED], + ['qa', NotificationKind.QA_FAILED], + ]); + expect(repo.all[0].summary).toContain('Falla en Safari'); + }); + + it('tells about a comment everyone but its author, once even when redelivered', async () => { + const { handler, repo } = setup(); + const comment = at( + new CommentAddedEvent('bob', { + id: 'c-1', + issueId: 'i-1', + projectId: 'p-1', + authorId: 'bob', + body: 'Ya está arreglado', + deleted: false, + createdAt: '2026-10-08T10:00:00.000Z', + editedAt: null, + }), + 1, + ); + await handler.handle(comment); + await handler.handle(comment); + + expect(repo.all.map((n) => n.userId)).toEqual(['qa']); + }); + + it('copies by e-mail only to whoever asked for it, when mail is configured', async () => { + const { handler, repo, mailed } = setup(true); + repo.emailOn.add('bob'); + await handler.handle( + at(new ProjectMemberAddedEvent('ana', 'p-1', 'bob', ProjectRole.MEMBER)), + ); + await handler.handle( + at( + new ProjectMemberAddedEvent('ana', 'p-1', 'qa', ProjectRole.MEMBER), + 3, + ), + ); + + expect(mailed).toEqual(['bob@example.org']); + expect(repo.all.map((n) => n.kind)).toEqual([ + NotificationKind.ADDED_TO_PROJECT, + NotificationKind.ADDED_TO_PROJECT, + ]); + }); +}); diff --git a/libs/api/tracker/application/src/lib/notifications/notifier.ts b/libs/api/tracker/application/src/lib/notifications/notifier.ts new file mode 100644 index 0000000..df84199 --- /dev/null +++ b/libs/api/tracker/application/src/lib/notifications/notifier.ts @@ -0,0 +1,110 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationKind, + NotificationReadModel, + SYSTEM_ACTOR_ID, + SYSTEM_ACTOR_NAME, + TrackerEvent, +} from '@task-stream/api/tracker/domain'; +import { ChangeFeed } from '../ports/change-feed'; +import { NotificationMailer } from '../ports/notification-mailer'; +import { NotificationsRepository } from '../ports/notifications.repository'; +import { UserDirectory } from '../ports/user-directory'; +import { versionOf } from '../event-handlers/read-model-notifier'; + +export interface NotificationDraft { + userId: string; + kind: NotificationKind; + summary: string; +} + +export interface NotificationContext { + projectId: string; + projectKey: string; + issueId: string | null; + issueKey: string | null; +} + +/** + * Delivers notifications: stored for the recipient, pushed live, and copied + * by e-mail to whoever asked for it. Nobody is told about what they did + * themselves, and each person is told once per event — the first reason wins. + */ +@Injectable() +export class Notifier { + private readonly logger = new Logger(Notifier.name); + + constructor( + private readonly notifications: NotificationsRepository, + private readonly changeFeed: ChangeFeed, + private readonly mailer: NotificationMailer, + private readonly users: UserDirectory, + ) {} + + async deliver( + event: TrackerEvent, + context: NotificationContext, + drafts: NotificationDraft[], + ): Promise { + const seen = new Set([event.actorId]); + const wanted = drafts.filter((draft) => { + if (seen.has(draft.userId)) return false; + seen.add(draft.userId); + return true; + }); + if (!wanted.length) return; + + const refs = await this.users.refs([event.actorId]); + const actor = + event.actorId === SYSTEM_ACTOR_ID + ? { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME } + : refs.of(event.actorId); + + const delivered: NotificationReadModel[] = []; + for (const draft of wanted) { + const notification: NotificationReadModel = { + id: `${event.aggregateId}:${versionOf(event)}:${draft.userId}`, + userId: draft.userId, + kind: draft.kind, + ...context, + actor, + summary: draft.summary, + readAt: null, + createdAt: event.occurredAt, + }; + // Delivered again (at-least-once): already told, nothing to repeat. + if (!(await this.notifications.insert(notification))) continue; + await this.changeFeed.publishNotification(notification); + delivered.push(notification); + } + + if (this.mailer.enabled && delivered.length) { + await this.email(delivered); + } + } + + /** A failed e-mail never blocks the event stream: it is logged and skipped. */ + private async email(notifications: NotificationReadModel[]): Promise { + const contacts = new Map( + (await this.users.contacts(notifications.map((n) => n.userId))).map( + (c) => [c.id, c], + ), + ); + for (const notification of notifications) { + const contact = contacts.get(notification.userId); + if (!contact?.email) continue; + if (!(await this.notifications.settings(notification.userId)).email) + continue; + try { + await this.mailer.send( + { name: contact.name, email: contact.email }, + notification, + ); + } catch (error) { + this.logger.warn( + `E-mail to ${notification.userId} failed: ${(error as Error).message}`, + ); + } + } + } +} diff --git a/libs/api/tracker/application/src/lib/ports/change-feed.ts b/libs/api/tracker/application/src/lib/ports/change-feed.ts index 1fc4358..12523d9 100644 --- a/libs/api/tracker/application/src/lib/ports/change-feed.ts +++ b/libs/api/tracker/application/src/lib/ports/change-feed.ts @@ -1,6 +1,7 @@ import { ActivityReadModel, CommentReadModel, + NotificationReadModel, IssueReadModel, ProjectReadModel, ReleaseReadModel, @@ -48,6 +49,10 @@ export abstract class ChangeFeed { notifyUserIds?: string[], ): Promise; + abstract publishNotification( + notification: NotificationReadModel, + ): Promise; + abstract issues(projectId: string): AsyncIterableIterator; abstract comments(issueId: string): AsyncIterableIterator; abstract activity( @@ -56,4 +61,7 @@ export abstract class ChangeFeed { abstract releases(projectId: string): AsyncIterableIterator; abstract project(projectId: string): AsyncIterableIterator; abstract userProjects(userId: string): AsyncIterableIterator; + abstract notifications( + userId: string, + ): AsyncIterableIterator; } diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index d9bfbd8..1360193 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -18,3 +18,5 @@ export * from './upsert-materialized-timeline.repository'; export * from './find-timeline.repository'; export * from './tracker-streams.repository'; export * from './attachment-storage'; +export * from './notifications.repository'; +export * from './notification-mailer'; diff --git a/libs/api/tracker/application/src/lib/ports/notification-mailer.ts b/libs/api/tracker/application/src/lib/ports/notification-mailer.ts new file mode 100644 index 0000000..affe7b1 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/notification-mailer.ts @@ -0,0 +1,16 @@ +import { NotificationReadModel } from '@task-stream/api/tracker/domain'; + +export interface MailRecipient { + name: string; + email: string; +} + +/** E-mail copies of notifications, for whoever asked for them. */ +export abstract class NotificationMailer { + /** False when no mail server is configured: nobody is offered e-mail. */ + abstract get enabled(): boolean; + abstract send( + to: MailRecipient, + notification: NotificationReadModel, + ): Promise; +} diff --git a/libs/api/tracker/application/src/lib/ports/notifications.repository.ts b/libs/api/tracker/application/src/lib/ports/notifications.repository.ts new file mode 100644 index 0000000..1a57690 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/notifications.repository.ts @@ -0,0 +1,23 @@ +import { + NotificationReadModel, + NotificationSettingsReadModel, +} from '@task-stream/api/tracker/domain'; + +/** + * Notifications are per-person state, not part of any aggregate: they are + * derived from events and read and dismissed by their recipient alone. + */ +export abstract class NotificationsRepository { + /** False when it was already there (the event was delivered again). */ + abstract insert(notification: NotificationReadModel): Promise; + /** Newest first. */ + abstract findForUser( + userId: string, + options: { unreadOnly: boolean; limit: number }, + ): Promise; + abstract countUnread(userId: string): Promise; + /** `ids` null: all of them. */ + abstract markRead(userId: string, ids: string[] | null): Promise; + abstract settings(userId: string): Promise; + abstract saveSettings(settings: NotificationSettingsReadModel): Promise; +} diff --git a/libs/api/tracker/application/src/lib/ports/user-directory.ts b/libs/api/tracker/application/src/lib/ports/user-directory.ts index 57dfcb9..cc4894a 100644 --- a/libs/api/tracker/application/src/lib/ports/user-directory.ts +++ b/libs/api/tracker/application/src/lib/ports/user-directory.ts @@ -6,6 +6,12 @@ export interface DirectoryUser { isActive: boolean; } +export interface UserContact { + id: string; + name: string; + email: string | null; +} + /** Outbound port to the identity context. */ export abstract class UserDirectory { /** @@ -17,6 +23,9 @@ export abstract class UserDirectory { accessToken?: string, ): Promise; + /** Names and e-mail addresses, for the users this application knows. */ + abstract contacts(userIds: string[]): Promise; + /** Resolves user ids to {id, name} for read models, in one lookup. */ abstract refs(userIds: (string | null | undefined)[]): Promise; } 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 569032a..17a41ab 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -16,6 +16,27 @@ import { ReadAccess } from './queries/read-access'; import { ReleasesService } from './releases.service'; import { SAGAS } from './sagas/sagas'; import { WorkflowsService } from './workflows.service'; +import { NotificationsService } from './notifications.service'; +import { Notifier } from './notifications/notifier'; +import { NotificationsEventHandler } from './notifications/notifications.event-handler'; +import { + GetMyNotificationSettingsQueryHandler, + GetMyNotificationsQueryHandler, +} from './notifications/notifications.queries'; +import { + MarkNotificationsReadCommandHandler, + UpdateNotificationSettingsCommandHandler, +} from './notifications/notifications.commands'; + +/** Notifications: derived from events, kept and dismissed per person. */ +const NOTIFICATIONS = [ + Notifier, + NotificationsEventHandler, + GetMyNotificationsQueryHandler, + GetMyNotificationSettingsQueryHandler, + MarkNotificationsReadCommandHandler, + UpdateNotificationSettingsCommandHandler, +]; const SERVICES = [ ProjectsService, @@ -24,6 +45,7 @@ const SERVICES = [ ReleasesService, ChangesService, WorkflowsService, + NotificationsService, ]; /** @@ -43,6 +65,7 @@ const SERVICES = [ ...QUERY_HANDLERS, ...EVENT_HANDLERS, ...SAGAS, + ...NOTIFICATIONS, ], exports: SERVICES, }) diff --git a/libs/api/tracker/application/src/testing/in-memory.ts b/libs/api/tracker/application/src/testing/in-memory.ts index 9a9c338..628ec4c 100644 --- a/libs/api/tracker/application/src/testing/in-memory.ts +++ b/libs/api/tracker/application/src/testing/in-memory.ts @@ -170,6 +170,16 @@ export class FakeUserDirectory extends UserDirectory { return this.users.get(userId) ?? null; } + async contacts(userIds: string[]) { + return userIds + .filter((id) => this.users.has(id)) + .map((id) => ({ + id, + name: this.users.get(id)?.name ?? id, + email: `${id}@example.org`, + })); + } + async refs(): Promise { const of = (id: string) => ({ id, name: this.users.get(id)?.name ?? id }); return { of, ofNullable: (id) => (id ? of(id) : null) }; diff --git a/libs/api/tracker/domain/src/lib/read-models/index.ts b/libs/api/tracker/domain/src/lib/read-models/index.ts index 23af381..1603135 100644 --- a/libs/api/tracker/domain/src/lib/read-models/index.ts +++ b/libs/api/tracker/domain/src/lib/read-models/index.ts @@ -6,3 +6,4 @@ export * from './release.read-model'; export * from './activity.read-model'; export * from './project-stats.read-model'; export * from './analytics.read-model'; +export * from './notification.read-model'; diff --git a/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts new file mode 100644 index 0000000..7acaebf --- /dev/null +++ b/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts @@ -0,0 +1,46 @@ +import { UserRefReadModel } from './user-ref.read-model'; + +/** Why someone is told. */ +export enum NotificationKind { + /** A ticket was assigned to you. */ + ASSIGNED = 'ASSIGNED', + /** A ticket you report or work on moved. */ + STATUS_CHANGED = 'STATUS_CHANGED', + /** A ticket you work on went back from QA. */ + QA_FAILED = 'QA_FAILED', + /** Someone commented on a ticket you report or work on. */ + COMMENTED = 'COMMENTED', + /** Someone wrote @you. */ + MENTIONED = 'MENTIONED', + /** A ticket you follow changed. */ + WATCHED = 'WATCHED', + /** You were added to a project. */ + ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', + /** A ticket of yours is due soon or has been still for too long. */ + REMINDER = 'REMINDER', +} + +/** + * One thing one person should know. Kept per recipient: it has its own read + * state, and nobody reads anyone else's. + */ +export class NotificationReadModel { + /** `::`: the same event never notifies twice. */ + id!: string; + userId!: string; + kind!: NotificationKind; + projectId!: string; + projectKey!: string; + issueId!: string | null; + issueKey!: string | null; + actor!: UserRefReadModel; + summary!: string; + readAt!: string | null; + createdAt!: string; +} + +/** What a person wants to be told by e-mail as well. */ +export class NotificationSettingsReadModel { + userId!: string; + email!: boolean; +} diff --git a/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts b/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts index 037f0e0..a6c3546 100644 --- a/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts +++ b/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts @@ -6,6 +6,7 @@ import { } from '@task-stream/api/identity/application'; import { DirectoryUser, + UserContact, UserDirectory, UserRefs, } from '@task-stream/api/tracker/application'; @@ -29,6 +30,17 @@ export class IdentityUserDirectory extends UserDirectory { : null; } + async contacts(userIds: string[]): Promise { + const wanted = [...new Set(userIds.filter(Boolean))]; + if (!wanted.length) return []; + const users = await this.queryBus.execute(new FindUsersByIdsQuery(wanted)); + return users.map((u) => ({ + id: u.id, + name: u.name, + email: u.isActive && u.email ? u.email : null, + })); + } + async refs(userIds: (string | null | undefined)[]): Promise { const wanted = [...new Set(userIds.filter((id): id is string => !!id))]; const users = wanted.length diff --git a/libs/api/tracker/infrastructure/src/lib/mail/smtp-notification.mailer.ts b/libs/api/tracker/infrastructure/src/lib/mail/smtp-notification.mailer.ts new file mode 100644 index 0000000..e976dfb --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mail/smtp-notification.mailer.ts @@ -0,0 +1,66 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MailRecipient, + NotificationMailer, +} from '@task-stream/api/tracker/application'; +import { NotificationReadModel } from '@task-stream/api/tracker/domain'; +import { createTransport, Transporter } from 'nodemailer'; + +/** + * E-mail copies through SMTP_URL (`smtps://user:pass@smtp.example.org`). + * Without it nothing is sent and nobody is offered e-mail. + */ +@Injectable() +export class SmtpNotificationMailer extends NotificationMailer { + private readonly logger = new Logger(SmtpNotificationMailer.name); + private readonly transport: Transporter | null; + private readonly from: string; + private readonly webUrl: string; + + constructor(config: ConfigService) { + super(); + const url = config.get('SMTP_URL'); + this.transport = url ? createTransport(url) : null; + this.from = + config.get('MAIL_FROM') || 'TaskStream '; + this.webUrl = (config.get('WEB_URL') || '').replace(/\/+$/, ''); + if (this.transport) this.logger.log('E-mail notifications enabled'); + } + + get enabled(): boolean { + return this.transport !== null; + } + + async send( + to: MailRecipient, + notification: NotificationReadModel, + ): Promise { + if (!this.transport) return; + const link = this.linkTo(notification); + const subject = notification.issueKey + ? `[${notification.issueKey}] ${notification.summary}` + : `[${notification.projectKey}] ${notification.summary}`; + await this.transport.sendMail({ + from: this.from, + to: { name: to.name, address: to.email }, + subject: subject.length > 160 ? `${subject.slice(0, 159)}…` : subject, + text: [ + `Hola ${to.name}:`, + '', + notification.summary, + ...(link ? ['', link] : []), + '', + '— TaskStream. Puedes dejar de recibir estos correos desde las notificaciones de la app.', + ].join('\n'), + }); + } + + private linkTo(notification: NotificationReadModel): string | null { + if (!this.webUrl) return null; + const project = encodeURIComponent(notification.projectKey); + return notification.issueKey + ? `${this.webUrl}/p/${project}/issues/${encodeURIComponent(notification.issueKey)}` + : `${this.webUrl}/p/${project}`; + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/notifications.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/notifications.repository.ts new file mode 100644 index 0000000..a0c2d62 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/notifications.repository.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { NotificationsRepository } from '@task-stream/api/tracker/application'; +import { + NotificationReadModel, + NotificationSettingsReadModel, +} from '@task-stream/api/tracker/domain'; +import { Model } from 'mongoose'; +import { + fromDoc, + NotificationDoc, + NotificationSettingsDoc, + ReadModels, +} from './read-model.schemas'; + +@Injectable() +export class MongoNotificationsRepository extends NotificationsRepository { + constructor( + @InjectModel(ReadModels.Notification) + private readonly notifications: Model, + @InjectModel(ReadModels.NotificationSettings) + private readonly settingsDocs: Model, + ) { + super(); + } + + async insert({ id, ...rest }: NotificationReadModel): Promise { + const result = await this.notifications.updateOne( + { _id: id }, + { $setOnInsert: rest }, + { upsert: true }, + ); + return result.upsertedCount > 0; + } + + async findForUser( + userId: string, + options: { unreadOnly: boolean; limit: number }, + ): Promise { + const docs = await this.notifications + .find({ userId, ...(options.unreadOnly ? { readAt: null } : {}) }) + .sort({ createdAt: -1 }) + .limit(options.limit) + .lean(); + return docs.map((doc) => fromDoc(doc) as NotificationReadModel); + } + + countUnread(userId: string): Promise { + return this.notifications.countDocuments({ userId, readAt: null }); + } + + async markRead(userId: string, ids: string[] | null): Promise { + await this.notifications.updateMany( + { userId, readAt: null, ...(ids ? { _id: { $in: ids } } : {}) }, + { $set: { readAt: new Date().toISOString() } }, + ); + } + + async settings(userId: string): Promise { + const doc = await this.settingsDocs + .findById(userId) + .lean(); + return { userId, email: doc?.email ?? false }; + } + + async saveSettings(settings: NotificationSettingsReadModel): Promise { + await this.settingsDocs.updateOne( + { _id: settings.userId }, + { $set: { email: settings.email } }, + { upsert: true }, + ); + } +} 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 04e775c..12b0fcb 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,6 +1,7 @@ import { ActivityReadModel, CommentReadModel, + NotificationReadModel, IssueLinkReadModel, IssueReadModel, IssueTimelineEntry, @@ -23,6 +24,11 @@ export type CommentDoc = Doc; export type ReleaseDoc = Doc; export type ActivityDoc = Doc; export type TimelineDoc = Doc; +export type NotificationDoc = Doc; +export interface NotificationSettingsDoc { + _id: string; + email: boolean; +} export const ReadModels = { Project: 'ProjectReadModel', @@ -31,6 +37,8 @@ export const ReadModels = { Release: 'ReleaseReadModel', Activity: 'ActivityReadModel', Timeline: 'IssueTimelineReadModel', + Notification: 'NotificationReadModel', + NotificationSettings: 'NotificationSettings', } as const; const userRef = { _id: false, id: String, name: String }; @@ -127,6 +135,24 @@ export const TimelineSchema = new Schema( ); TimelineSchema.index({ projectId: 1, at: 1 }); +export const NotificationSchema = new Schema( + { + _id: String, + userId: String, + readAt: String, + createdAt: String, + actor: userRef, + }, + { ...loose, collection: 'notifications' }, +); +NotificationSchema.index({ userId: 1, createdAt: -1 }); +NotificationSchema.index({ userId: 1, readAt: 1 }); + +export const NotificationSettingsSchema = new Schema( + { _id: String, email: Boolean }, + { ...loose, collection: 'notification_settings' }, +); + export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Project, schema: ProjectSchema }, { name: ReadModels.Issue, schema: IssueSchema }, @@ -134,6 +160,8 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Release, schema: ReleaseSchema }, { name: ReadModels.Activity, schema: ActivitySchema }, { name: ReadModels.Timeline, schema: TimelineSchema }, + { name: ReadModels.Notification, schema: NotificationSchema }, + { name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema }, ]; export function fromDoc( diff --git a/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts b/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts index 45fb877..8d535c2 100644 --- a/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts +++ b/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts @@ -7,7 +7,10 @@ import { ProjectChange, ReleaseChange, } from '@task-stream/api/tracker/application'; -import { ActivityReadModel } from '@task-stream/api/tracker/domain'; +import { + ActivityReadModel, + NotificationReadModel, +} from '@task-stream/api/tracker/domain'; import { RedisPubSub } from 'graphql-redis-subscriptions'; /** Redis pub/sub channels (one per project / issue / user). Works across instances. */ @@ -18,6 +21,7 @@ const Channels = { release: (projectId: string) => `tracker.release.${projectId}`, project: (projectId: string) => `tracker.project.${projectId}`, userProjects: (userId: string) => `tracker.user-projects.${userId}`, + notifications: (userId: string) => `tracker.notifications.${userId}`, }; @Injectable() @@ -57,6 +61,17 @@ export class RedisChangeFeed extends ChangeFeed { ); } + publishNotification(notification: NotificationReadModel): Promise { + return this.pubSub.publish( + Channels.notifications(notification.userId), + notification, + ); + } + + notifications(userId: string): AsyncIterableIterator { + return this.pubSub.asyncIterableIterator(Channels.notifications(userId)); + } + issues(projectId: string): AsyncIterableIterator { return this.pubSub.asyncIterableIterator(Channels.issue(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 e8461dc..133a4b8 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -5,6 +5,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AttachmentStorage, ChangeFeed, + NotificationMailer, + NotificationsRepository, FindActivityRepository, FindCommentsRepository, FindIssuesRepository, @@ -52,6 +54,8 @@ import { import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository'; import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository'; import { RedisChangeFeed } from './realtime/redis-change-feed'; +import { MongoNotificationsRepository } from './mongo/notifications.repository'; +import { SmtpNotificationMailer } from './mail/smtp-notification.mailer'; import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage'; const PORTS = [ @@ -100,6 +104,9 @@ const PORTS = [ }, // Attached files → disk (ATTACHMENTS_DIR) { provide: AttachmentStorage, useClass: FileSystemAttachmentStorage }, + // Notifications → MongoDB (per person), copies by e-mail → SMTP (optional) + { provide: NotificationsRepository, useClass: MongoNotificationsRepository }, + { provide: NotificationMailer, useClass: SmtpNotificationMailer }, // Realtime → Redis pub/sub { provide: ChangeFeed, useClass: RedisChangeFeed }, // Identity context (anti-corruption layer) diff --git a/libs/api/tracker/presenters/src/lib/graphql/enums.ts b/libs/api/tracker/presenters/src/lib/graphql/enums.ts index ff78256..32e09a6 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/enums.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/enums.ts @@ -2,6 +2,7 @@ import { registerEnumType } from '@nestjs/graphql'; import { IssueLinkType, IssuePriority, + NotificationKind, ProjectRole, ReleaseStatus, StatusCategory, @@ -22,3 +23,7 @@ registerEnumType(IssueLinkType, { name: 'IssueLinkType' }); registerEnumType(ProjectRole, { name: 'ProjectRole' }); registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' }); registerEnumType(LinkDirection, { name: 'LinkDirection' }); +registerEnumType(NotificationKind, { + name: 'NotificationKind', + description: 'Why someone is told', +}); diff --git a/libs/api/tracker/presenters/src/lib/graphql/notification.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/notification.resolver.ts new file mode 100644 index 0000000..113179b --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/graphql/notification.resolver.ts @@ -0,0 +1,50 @@ +import { Args, ID, Int, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { User } from '@task-stream/api/identity/domain'; +import { CurrentUser } from '@task-stream/api/identity/presenters'; +import { NotificationsService } from '@task-stream/api/tracker/application'; +import { + MyNotificationsGql, + NotificationGql, + NotificationSettingsGql, +} from './types'; + +/** The signed-in person's notifications. */ +@Resolver(() => NotificationGql) +export class NotificationResolver { + constructor(private readonly notifications: NotificationsService) {} + + @Query(() => MyNotificationsGql, { + description: 'My notifications, newest first, and how many are unread', + }) + myNotifications( + @CurrentUser() me: User, + @Args('unreadOnly', { defaultValue: false }) unreadOnly: boolean, + @Args('limit', { type: () => Int, defaultValue: 30 }) limit: number, + ) { + return this.notifications.mine(me.id, { unreadOnly, limit }); + } + + @Query(() => NotificationSettingsGql) + notificationSettings(@CurrentUser() me: User) { + return this.notifications.settings(me.id); + } + + @Mutation(() => Boolean, { + description: 'Mark these as read, or all of them when `ids` is omitted', + }) + async markNotificationsRead( + @CurrentUser() me: User, + @Args('ids', { type: () => [ID], nullable: true }) ids?: string[] | null, + ) { + await this.notifications.markRead(me.id, ids); + return true; + } + + @Mutation(() => NotificationSettingsGql) + updateNotificationSettings( + @CurrentUser() me: User, + @Args('email') email: boolean, + ) { + return this.notifications.updateSettings(me.id, { email }); + } +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts index 37e2c90..a333683 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts @@ -1,11 +1,15 @@ import { Args, ID, Resolver, Subscription } from '@nestjs/graphql'; import { User } from '@task-stream/api/identity/domain'; import { CurrentUser } from '@task-stream/api/identity/presenters'; -import { ChangesService } from '@task-stream/api/tracker/application'; +import { + ChangesService, + NotificationsService, +} from '@task-stream/api/tracker/application'; import { ActivityGql, CommentChangeGql, IssueChangeGql, + NotificationGql, ProjectChangeGql, ReleaseChangeGql, } from './types'; @@ -18,7 +22,10 @@ const asIs = { resolve: (payload: T) => payload }; */ @Resolver() export class SubscriptionResolver { - constructor(private readonly changes: ChangesService) {} + constructor( + private readonly changes: ChangesService, + private readonly notifications: NotificationsService, + ) {} @Subscription(() => IssueChangeGql, { ...asIs, @@ -74,4 +81,12 @@ export class SubscriptionResolver { myProjectsChanged(@CurrentUser() me: User) { return this.changes.watchMyProjects(me.id); } + + @Subscription(() => NotificationGql, { + ...asIs, + description: 'My notifications, as they happen', + }) + notificationAdded(@CurrentUser() me: User) { + return this.notifications.watch(me.id); + } } diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 223be6f..d6ed4e4 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -2,6 +2,7 @@ import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql'; import { IssueLinkType, IssuePriority, + NotificationKind, ProjectRole, ReleaseStatus, StatusCategory, @@ -242,6 +243,37 @@ export class ActivityGql { @Field() occurredAt!: string; } +@ObjectType('Notification') +export class NotificationGql { + @Field(() => ID) id!: string; + @Field(() => NotificationKind) kind!: NotificationKind; + @Field(() => ID) projectId!: string; + @Field() projectKey!: string; + @Field(() => ID, { nullable: true }) issueId!: string | null; + @Field(() => String, { nullable: true }) issueKey!: string | null; + @Field(() => UserRefGql) actor!: UserRefGql; + @Field() summary!: string; + @Field(() => String, { nullable: true, description: 'Null: unread' }) + readAt!: string | null; + @Field() createdAt!: string; +} + +@ObjectType('MyNotifications') +export class MyNotificationsGql { + @Field(() => [NotificationGql]) items!: NotificationGql[]; + @Field(() => Int, { description: 'All my unread ones, not just these' }) + unread!: number; +} + +@ObjectType('NotificationSettings') +export class NotificationSettingsGql { + @Field({ description: 'Copies by e-mail' }) email!: boolean; + @Field({ + description: 'Whether this installation can send e-mail at all', + }) + emailAvailable!: boolean; +} + @ObjectType('CommandResult', { description: 'Writes are acknowledged immediately; the read model catches up asynchronously. ' + 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 f9afd1a..40d519a 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 @@ -637,3 +637,39 @@ export class PublishReleaseDto { @IsBoolean() force = false; } + +// ---- Notifications ---------------------------------------------------------- + +export class MyNotificationsQueryDto { + @ApiPropertyOptional({ default: false, description: 'Only unread ones' }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + unreadOnly = false; + + @ApiPropertyOptional({ type: Number, default: 30, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit = 30; +} + +export class MarkNotificationsReadDto { + @ApiPropertyOptional({ + type: [String], + description: 'Omit to mark all of them', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(200) + @IsString({ each: true }) + ids?: string[]; +} + +export class NotificationSettingsDto { + @ApiProperty({ description: 'Copies by e-mail' }) + @IsBoolean() + email!: boolean; +} 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 bb6e90c..6a0f022 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 @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IssueLinkType, IssuePriority, + NotificationKind, ProjectRole, ReleaseStatus, SETTING_COLORS, @@ -354,3 +355,40 @@ export class ReleaseBurnupDto { scope!: number[]; @ApiProperty({ type: [Number] }) done!: number[]; } + +// ---- Notifications ---------------------------------------------------------- + +export class NotificationDto { + @ApiProperty() id!: string; + @ApiProperty({ enum: NotificationKind, enumName: 'NotificationKind' }) + kind!: NotificationKind; + @ApiProperty({ format: 'uuid' }) projectId!: string; + @ApiProperty({ example: 'WEB' }) projectKey!: string; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + issueId!: string | null; + @ApiProperty({ type: String, nullable: true, example: 'WEB-12' }) + issueKey!: string | null; + @ApiProperty({ type: UserRefDto }) actor!: UserRefDto; + @ApiProperty({ example: 'Ana te asignó WEB-12: Login roto en Safari' }) + summary!: string; + @ApiProperty({ + type: String, + nullable: true, + format: 'date-time', + description: 'Null: unread', + }) + readAt!: string | null; + @ApiProperty({ format: 'date-time' }) createdAt!: string; +} + +export class MyNotificationsDto { + @ApiProperty({ type: [NotificationDto] }) items!: NotificationDto[]; + @ApiProperty({ description: 'All my unread ones, not just these' }) + unread!: number; +} + +export class NotificationSettingsResponseDto { + @ApiProperty({ description: 'Copies by e-mail' }) email!: boolean; + @ApiProperty({ description: 'Whether this installation can send e-mail' }) + emailAvailable!: boolean; +} diff --git a/libs/api/tracker/presenters/src/lib/http/notifications.controller.ts b/libs/api/tracker/presenters/src/lib/http/notifications.controller.ts new file mode 100644 index 0000000..dcea845 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/notifications.controller.ts @@ -0,0 +1,70 @@ +import { Body, Controller, Get, Post, Put, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, 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 { NotificationsService } from '@task-stream/api/tracker/application'; +import { + MarkNotificationsReadDto, + MyNotificationsQueryDto, + NotificationSettingsDto, +} from './dto/requests.dto'; +import { + MyNotificationsDto, + NotificationSettingsResponseDto, +} from './dto/responses.dto'; + +/** REST adapter of the signed-in person's notifications. */ +@ApiTags('Users') +@ApiPireonAuth() +@Controller('me') +export class NotificationsController { + constructor(private readonly notifications: NotificationsService) {} + + @Get('notifications') + @ApiOperation({ summary: 'My notifications, newest first' }) + @ApiOkResponse({ type: MyNotificationsDto }) + @ApiReadErrors() + mine(@CurrentUser() me: User, @Query() query: MyNotificationsQueryDto) { + return this.notifications.mine(me.id, query); + } + + @Post('notifications/read') + @ApiOperation({ + summary: 'Mark notifications as read (all of them without `ids`)', + }) + @ApiOkResponse({ description: 'Done' }) + @ApiCommandErrors() + async markRead( + @CurrentUser() me: User, + @Body() body: MarkNotificationsReadDto, + ) { + await this.notifications.markRead(me.id, body.ids); + return { ok: true }; + } + + @Get('notification-settings') + @ApiOperation({ summary: 'Whether I get copies by e-mail' }) + @ApiOkResponse({ type: NotificationSettingsResponseDto }) + @ApiReadErrors() + settings(@CurrentUser() me: User) { + return this.notifications.settings(me.id); + } + + @Put('notification-settings') + @ApiOperation({ summary: 'Turn e-mail copies on or off' }) + @ApiOkResponse({ type: NotificationSettingsResponseDto }) + @ApiCommandErrors() + updateSettings( + @CurrentUser() me: User, + @Body() body: NotificationSettingsDto, + ) { + return this.notifications.updateSettings(me.id, body); + } +} 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 9caf8cf..79abce3 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { MulterModule } from '@nestjs/platform-express'; import './graphql/enums'; import { CommentResolver } from './graphql/comment.resolver'; +import { NotificationResolver } from './graphql/notification.resolver'; import { IssueResolver } from './graphql/issue.resolver'; import { ProjectResolver } from './graphql/project.resolver'; import { ReleaseResolver } from './graphql/release.resolver'; @@ -14,6 +15,7 @@ import { ExploreController } from './http/explore.controller'; import { ProjectsController } from './http/projects.controller'; import { Refs } from './http/refs'; import { ReleasesController } from './http/releases.controller'; +import { NotificationsController } from './http/notifications.controller'; /** * Driving adapters of the tracker context: GraphQL (queries, mutations, @@ -42,6 +44,7 @@ import { ReleasesController } from './http/releases.controller'; CommentsController, ReleasesController, ExploreController, + NotificationsController, ], providers: [ Refs, @@ -50,6 +53,7 @@ import { ReleasesController } from './http/releases.controller'; CommentResolver, ReleaseResolver, SubscriptionResolver, + NotificationResolver, ], }) export class TrackerPresentersModule { diff --git a/libs/web/shared/ui/src/lib/icons.ts b/libs/web/shared/ui/src/lib/icons.ts index 0e0e7f4..d299489 100644 --- a/libs/web/shared/ui/src/lib/icons.ts +++ b/libs/web/shared/ui/src/lib/icons.ts @@ -17,6 +17,7 @@ import { MatIconRegistry } from '@angular/material/icon'; export const APP_ICONS: Record = { 'arrow-forward': '', + bell: '', briefcase: '', buildings: diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index 34b0b83..f5fc012 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -5,3 +5,4 @@ export * from './lib/facades/board.facade'; export * from './lib/facades/issue.facade'; export * from './lib/facades/home.facade'; export * from './lib/facades/explore.facade'; +export * from './lib/facades/inbox.facade'; diff --git a/libs/web/tracker/application/src/lib/events/inbox.events.ts b/libs/web/tracker/application/src/lib/events/inbox.events.ts new file mode 100644 index 0000000..95fa649 --- /dev/null +++ b/libs/web/tracker/application/src/lib/events/inbox.events.ts @@ -0,0 +1,37 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import { + Notification, + NotificationSettings, +} from '@task-stream/web/tracker/domain'; + +/* Notifications are "the inbox" here, so as not to clash with `Notifications` (the toasts port). */ + +export const inboxPageEvents = eventGroup({ + source: 'Inbox', + events: { + /** Signed in: load, and follow new ones. */ + opened: type(), + /** Signed out: forget everything. */ + closed: type(), + markReadRequested: type<{ ids?: string[] }>(), + emailToggled: type(), + }, +}); + +export const inboxApiEvents = eventGroup({ + source: 'Inbox API', + events: { + loaded: type<{ items: Notification[]; unread: number }>(), + settingsLoaded: type(), + markedRead: type<{ ids?: string[]; at: string }>(), + failed: type(), + }, +}); + +export const inboxRealtimeEvents = eventGroup({ + source: 'Inbox Realtime', + events: { + arrived: type(), + }, +}); diff --git a/libs/web/tracker/application/src/lib/facades/facades.spec.ts b/libs/web/tracker/application/src/lib/facades/facades.spec.ts index 3495210..fc8b9d4 100644 --- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts +++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts @@ -7,6 +7,7 @@ import { AttachmentsGateway, CommentsGateway, IssuesGateway, + NotificationsGateway, Project, ProjectRole, ProjectsGateway, @@ -21,6 +22,7 @@ import { projectPageEvents } from '../events/project.events'; import { Notifications } from '../notifications'; import { BoardFacade, provideBoardFacade } from './board.facade'; import { HomeFacade } from './home.facade'; +import { InboxFacade } from './inbox.facade'; import { IssueFacade, provideIssueFacade } from './issue.facade'; import { ProjectFacade, provideProjectFacade } from './project.facade'; @@ -307,3 +309,84 @@ describe('HomeFacade', () => { expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']); }); }); + +describe('InboxFacade', () => { + const note = (id: string, readAt: string | null = null) => + ({ + id, + kind: 'ASSIGNED', + projectId: 'p1', + projectKey: 'WEB', + issueId: 'i1', + issueKey: 'WEB-1', + actor: { id: 'ana', name: 'Ana' }, + summary: `Aviso ${id}`, + readAt, + createdAt: '2026-10-08T10:00:00.000Z', + }) as never; + + function inbox(signedIn = true) { + const arrivals = new Subject(); + const gateway = { + mine: vi.fn(() => + of({ items: [note('n1'), note('n0', 'x')], unread: 1 }), + ), + settings: vi.fn(() => of({ email: false, emailAvailable: true })), + markRead: vi.fn(() => of(undefined)), + updateSettings: vi.fn((email: boolean) => + of({ email, emailAvailable: true }), + ), + }; + TestBed.configureTestingModule({ + providers: [ + { provide: NotificationsGateway, useValue: gateway }, + { + provide: TrackerRealtimeGateway, + useValue: { notifications: () => arrivals }, + }, + { + provide: Notifications, + useValue: { success: vi.fn(), error: vi.fn() }, + }, + { + provide: AuthFacade, + useValue: { isAuthenticated: signal(signedIn) }, + }, + ], + }); + const facade = TestBed.inject(InboxFacade); + TestBed.tick(); + return { facade, gateway, arrivals }; + } + + it('loads once signed in and counts what arrives live', () => { + const { facade, arrivals } = inbox(); + + expect(facade.unread()).toBe(1); + arrivals.next(note('n2')); + arrivals.next(note('n2')); + + expect(facade.unread()).toBe(2); + expect(facade.items().map((n) => n.id)).toEqual(['n2', 'n1', 'n0']); + }); + + it('marks one or all as read', () => { + const { facade, gateway, arrivals } = inbox(); + arrivals.next(note('n2')); + + facade.markRead(['n2']); + expect(facade.unread()).toBe(1); + facade.markRead(); + + expect(gateway.markRead).toHaveBeenLastCalledWith(undefined); + expect(facade.unread()).toBe(0); + expect(facade.items().every((n) => n.readAt)).toBe(true); + }); + + it('asks nothing without a session', () => { + const { gateway, facade } = inbox(false); + + expect(gateway.mine).not.toHaveBeenCalled(); + expect(facade.items()).toEqual([]); + }); +}); diff --git a/libs/web/tracker/application/src/lib/facades/inbox.facade.ts b/libs/web/tracker/application/src/lib/facades/inbox.facade.ts new file mode 100644 index 0000000..a71e392 --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/inbox.facade.ts @@ -0,0 +1,37 @@ +import { effect, inject, Injectable } from '@angular/core'; +import { injectDispatch } from '@ngrx/signals/events'; +import { AuthFacade } from '@task-stream/web/identity/application'; +import { inboxPageEvents } from '../events/inbox.events'; +import { InboxStore } from '../stores/inbox.store'; + +/** + * The signed-in person's notifications for presenters: loaded and followed + * live while there is a session, forgotten when it ends. + */ +@Injectable({ providedIn: 'root' }) +export class InboxFacade { + private readonly store = inject(InboxStore); + private readonly dispatch = injectDispatch(inboxPageEvents); + private readonly auth = inject(AuthFacade); + + readonly items = this.store.items; + readonly unread = this.store.unread; + readonly hasUnread = this.store.hasUnread; + readonly settings = this.store.settings; + + constructor() { + effect(() => { + if (this.auth.isAuthenticated()) this.dispatch.opened(); + else this.dispatch.closed(); + }); + } + + /** Without ids: all of them. */ + markRead(ids?: string[]): void { + this.dispatch.markReadRequested({ ids }); + } + + setEmail(enabled: boolean): void { + this.dispatch.emailToggled(enabled); + } +} diff --git a/libs/web/tracker/application/src/lib/stores/inbox.store.ts b/libs/web/tracker/application/src/lib/stores/inbox.store.ts new file mode 100644 index 0000000..1b9fd7d --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/inbox.store.ts @@ -0,0 +1,143 @@ +import { computed, inject } from '@angular/core'; +import { mapResponse } from '@ngrx/operators'; +import { signalStore, withComputed, withState } from '@ngrx/signals'; +import { + Events, + on, + withEventHandlers, + withReducer, +} from '@ngrx/signals/events'; +import { + Notification, + NotificationSettings, + NotificationsGateway, + TrackerRealtimeGateway, +} from '@task-stream/web/tracker/domain'; +import { + catchError, + EMPTY, + map, + merge, + mergeMap, + switchMap, + takeUntil, + tap, +} from 'rxjs'; +import { + inboxApiEvents, + inboxPageEvents, + inboxRealtimeEvents, +} from '../events/inbox.events'; +import { describeError, Notifications } from '../notifications'; + +interface InboxState { + items: Notification[]; + unread: number; + settings: NotificationSettings | null; +} + +const LIMIT = 30; + +/** The signed-in person's notifications, live. One for the whole app. */ +export const InboxStore = signalStore( + { providedIn: 'root' }, + withState({ items: [], unread: 0, settings: null }), + withComputed(({ unread }) => ({ + hasUnread: computed(() => unread() > 0), + })), + withReducer( + on(inboxPageEvents.closed, () => ({ + items: [], + unread: 0, + settings: null, + })), + on(inboxApiEvents.loaded, ({ payload }) => payload), + on(inboxApiEvents.settingsLoaded, ({ payload }) => ({ settings: payload })), + on(inboxRealtimeEvents.arrived, ({ payload }, state) => + state.items.some((n) => n.id === payload.id) + ? {} + : { + items: [payload, ...state.items].slice(0, LIMIT), + unread: state.unread + 1, + }, + ), + on(inboxApiEvents.markedRead, ({ payload }, state) => { + const ids = payload.ids ? new Set(payload.ids) : null; + const items = state.items.map((n) => + !n.readAt && (!ids || ids.has(n.id)) ? { ...n, readAt: payload.at } : n, + ); + const marked = state.items.filter( + (n) => !n.readAt && (!ids || ids.has(n.id)), + ).length; + return { + items, + unread: ids ? Math.max(0, state.unread - marked) : 0, + }; + }), + ), + withEventHandlers( + ( + _store, + events = inject(Events), + gateway = inject(NotificationsGateway), + realtime = inject(TrackerRealtimeGateway), + toasts = inject(Notifications), + ) => { + const closed$ = events.on(inboxPageEvents.closed); + return { + load$: events.on(inboxPageEvents.opened).pipe( + switchMap(() => + gateway.mine(false, LIMIT).pipe( + mapResponse({ + next: (page) => inboxApiEvents.loaded(page), + error: (error) => inboxApiEvents.failed(describeError(error)), + }), + ), + ), + ), + settings$: events.on(inboxPageEvents.opened).pipe( + switchMap(() => + gateway.settings().pipe( + map((settings) => inboxApiEvents.settingsLoaded(settings)), + // Without settings the bell still works; only the e-mail switch hides. + catchError(() => EMPTY), + ), + ), + ), + live$: events.on(inboxPageEvents.opened).pipe( + switchMap(() => realtime.notifications().pipe(takeUntil(closed$))), + map((notification) => inboxRealtimeEvents.arrived(notification)), + ), + markRead$: events.on(inboxPageEvents.markReadRequested).pipe( + mergeMap(({ payload }) => + gateway.markRead(payload.ids).pipe( + mapResponse({ + next: () => + inboxApiEvents.markedRead({ + ids: payload.ids, + at: new Date().toISOString(), + }), + error: (error) => inboxApiEvents.failed(describeError(error)), + }), + ), + ), + ), + email$: events.on(inboxPageEvents.emailToggled).pipe( + switchMap(({ payload }) => + gateway.updateSettings(payload).pipe( + mapResponse({ + next: (settings) => inboxApiEvents.settingsLoaded(settings), + error: (error) => inboxApiEvents.failed(describeError(error)), + }), + ), + ), + ), + feedback$: merge( + events + .on(inboxApiEvents.failed) + .pipe(tap(({ payload }) => toasts.error(payload))), + ), + }; + }, + ), +); diff --git a/libs/web/tracker/domain/src/lib/models/enums.ts b/libs/web/tracker/domain/src/lib/models/enums.ts index 807c3e2..c99fd23 100644 --- a/libs/web/tracker/domain/src/lib/models/enums.ts +++ b/libs/web/tracker/domain/src/lib/models/enums.ts @@ -38,3 +38,15 @@ export enum ReleaseStatus { RELEASED = 'RELEASED', ARCHIVED = 'ARCHIVED', } + +/** Why someone is told. */ +export enum NotificationKind { + ASSIGNED = 'ASSIGNED', + STATUS_CHANGED = 'STATUS_CHANGED', + QA_FAILED = 'QA_FAILED', + COMMENTED = 'COMMENTED', + MENTIONED = 'MENTIONED', + WATCHED = 'WATCHED', + ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', + REMINDER = 'REMINDER', +} diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 12e249d..1fadbb0 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -1,4 +1,5 @@ import { + NotificationKind, IssueLinkType, IssuePriority, ProjectRole, @@ -187,3 +188,25 @@ export interface CommandResult { key?: string | null; version: number; } + +/** Something the signed-in person should know. */ +export interface Notification { + id: string; + kind: NotificationKind; + projectId: string; + projectKey: string; + issueId: string | null; + issueKey: string | null; + actor: UserRef; + summary: string; + /** Null: unread. */ + readAt: string | null; + createdAt: string; +} + +export interface NotificationSettings { + /** Copies by e-mail. */ + email: boolean; + /** Whether this installation can send e-mail at all. */ + emailAvailable: boolean; +} diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index fcc58f9..184ffb9 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -8,6 +8,8 @@ import { Issue, IssuePage, IssueSummary, + Notification, + NotificationSettings, Project, ProjectAnalytics, ProjectStats, @@ -130,6 +132,18 @@ export abstract class UsersGateway { abstract search(term: string): Observable; } +/** The signed-in person's notifications. */ +export abstract class NotificationsGateway { + abstract mine( + unreadOnly: boolean, + limit: number, + ): Observable<{ items: Notification[]; unread: number }>; + /** Without ids: all of them. */ + abstract markRead(ids?: string[]): Observable; + abstract settings(): Observable; + abstract updateSettings(email: boolean): Observable; +} + /** Realtime (GraphQL subscriptions over WebSocket). */ export interface IssueChanged { action: string; @@ -158,4 +172,5 @@ export abstract class TrackerRealtimeGateway { abstract releaseChanges(projectId: string): Observable; abstract projectChanges(projectId: string): Observable; abstract myProjectsChanges(): Observable; + abstract notifications(): Observable; } diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-notifications.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-notifications.gateway.ts new file mode 100644 index 0000000..4563309 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-notifications.gateway.ts @@ -0,0 +1,49 @@ +import { inject, Injectable } from '@angular/core'; +import { + Notification, + NotificationSettings, + NotificationsGateway, +} from '@task-stream/web/tracker/domain'; +import { map, Observable } from 'rxjs'; +import { + MarkNotificationsReadDocument, + MyNotificationsDocument, + NotificationSettingsDocument, + UpdateNotificationSettingsDocument, +} from '../graphql/generated'; +import { GraphqlOperations } from './graphql-operations'; + +@Injectable() +export class GraphqlNotificationsGateway extends NotificationsGateway { + private readonly gql = inject(GraphqlOperations); + + mine( + unreadOnly: boolean, + limit: number, + ): Observable<{ items: Notification[]; unread: number }> { + return this.gql.query(MyNotificationsDocument, { unreadOnly, limit }).pipe( + map(({ myNotifications }) => ({ + unread: myNotifications.unread, + items: myNotifications.items as Notification[], + })), + ); + } + + markRead(ids?: string[]): Observable { + return this.gql + .mutate(MarkNotificationsReadDocument, { ids: ids ?? null }) + .pipe(map(() => undefined)); + } + + settings(): Observable { + return this.gql + .query(NotificationSettingsDocument) + .pipe(map((d) => ({ ...d.notificationSettings }))); + } + + updateSettings(email: boolean): Observable { + return this.gql + .mutate(UpdateNotificationSettingsDocument, { email }) + .pipe(map((d) => ({ ...d.updateNotificationSettings }))); + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts index c2a503e..f1fecbb 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts @@ -4,6 +4,7 @@ import { Activity, CommentChanged, IssueChanged, + Notification, ProjectChanged, Release, TrackerRealtimeGateway, @@ -14,6 +15,7 @@ import { CommentChangedDocument, IssueChangedDocument, MyProjectsChangedDocument, + NotificationAddedDocument, ProjectChangedDocument, ReleaseChangedDocument, } from '../graphql/generated'; @@ -98,6 +100,14 @@ export class GraphqlRealtimeGateway extends TrackerRealtimeGateway { .pipe(map((d) => toProjectChanged(d.myProjectsChanged))), ); } + + notifications(): Observable { + return this.signedIn(() => + this.gql + .subscribe(NotificationAddedDocument) + .pipe(map((d) => d.notificationAdded as Notification)), + ); + } } const toProjectChanged = (change: { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 099042f..85327a6 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -96,6 +96,17 @@ export type LinkIssuesInput = { type: IssueLinkType; }; +/** Why someone is told */ +export type NotificationKind = + | 'ADDED_TO_PROJECT' + | 'ASSIGNED' + | 'COMMENTED' + | 'MENTIONED' + | 'QA_FAILED' + | 'REMINDER' + | 'STATUS_CHANGED' + | 'WATCHED'; + export type ProjectMemberInput = { projectId: string; role?: ProjectRole; @@ -524,6 +535,83 @@ export type MyIssuesQuery = { }; }; +export type NotificationFieldsFragment = { + id: string; + kind: NotificationKind; + projectId: string; + projectKey: string; + issueId: string | null; + issueKey: string | null; + summary: string; + readAt: string | null; + createdAt: string; + actor: { id: string; name: string }; +}; + +export type MyNotificationsQueryVariables = Exact<{ + unreadOnly: boolean; + limit: number; +}>; + +export type MyNotificationsQuery = { + myNotifications: { + unread: number; + items: Array<{ + id: string; + kind: NotificationKind; + projectId: string; + projectKey: string; + issueId: string | null; + issueKey: string | null; + summary: string; + readAt: string | null; + createdAt: string; + actor: { id: string; name: string }; + }>; + }; +}; + +export type NotificationSettingsQueryVariables = Exact<{ + [key: string]: never; +}>; + +export type NotificationSettingsQuery = { + notificationSettings: { email: boolean; emailAvailable: boolean }; +}; + +export type MarkNotificationsReadMutationVariables = Exact<{ + ids?: Array | string | null | undefined; +}>; + +export type MarkNotificationsReadMutation = { markNotificationsRead: boolean }; + +export type UpdateNotificationSettingsMutationVariables = Exact<{ + email: boolean; +}>; + +export type UpdateNotificationSettingsMutation = { + updateNotificationSettings: { email: boolean; emailAvailable: boolean }; +}; + +export type NotificationAddedSubscriptionVariables = Exact<{ + [key: string]: never; +}>; + +export type NotificationAddedSubscription = { + notificationAdded: { + id: string; + kind: NotificationKind; + projectId: string; + projectKey: string; + issueId: string | null; + issueKey: string | null; + summary: string; + readAt: string | null; + createdAt: string; + actor: { id: string; name: string }; + }; +}; + export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>; export type MyProjectsQuery = { @@ -1559,6 +1647,61 @@ export const CommandResultFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; +export const NotificationFieldsFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'NotificationFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Notification' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectKey' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueKey' } }, + { kind: 'Field', name: { kind: 'Name', value: 'summary' } }, + { kind: 'Field', name: { kind: 'Name', value: 'readAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'actor' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + { + 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' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const BoardDocument = { kind: 'Document', definitions: [ @@ -3155,6 +3298,358 @@ export const MyIssuesDocument = { }, ], } as unknown as DocumentNode; +export const MyNotificationsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'MyNotifications' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'unreadOnly' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Boolean' }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'limit' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'myNotifications' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'unreadOnly' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'unreadOnly' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'limit' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'limit' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'unread' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'items' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'NotificationFields' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + 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: 'NotificationFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Notification' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectKey' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueKey' } }, + { kind: 'Field', name: { kind: 'Name', value: 'summary' } }, + { kind: 'Field', name: { kind: 'Name', value: 'readAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'actor' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + MyNotificationsQuery, + MyNotificationsQueryVariables +>; +export const NotificationSettingsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'NotificationSettings' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'notificationSettings' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'email' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'emailAvailable' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + NotificationSettingsQuery, + NotificationSettingsQueryVariables +>; +export const MarkNotificationsReadDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'MarkNotificationsRead' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'ids' } }, + type: { + kind: 'ListType', + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'markNotificationsRead' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'ids' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'ids' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + MarkNotificationsReadMutation, + MarkNotificationsReadMutationVariables +>; +export const UpdateNotificationSettingsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'UpdateNotificationSettings' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'email' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Boolean' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'updateNotificationSettings' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'email' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'email' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'email' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'emailAvailable' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + UpdateNotificationSettingsMutation, + UpdateNotificationSettingsMutationVariables +>; +export const NotificationAddedDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'subscription', + name: { kind: 'Name', value: 'NotificationAdded' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'notificationAdded' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'NotificationFields' }, + }, + ], + }, + }, + ], + }, + }, + { + 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: 'NotificationFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Notification' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectKey' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueKey' } }, + { kind: 'Field', name: { kind: 'Name', value: 'summary' } }, + { kind: 'Field', name: { kind: 'Name', value: 'readAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'actor' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + NotificationAddedSubscription, + NotificationAddedSubscriptionVariables +>; export const MyProjectsDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/notifications.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/notifications.graphql new file mode 100644 index 0000000..7978676 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/graphql/notifications.graphql @@ -0,0 +1,47 @@ +fragment NotificationFields on Notification { + id + kind + projectId + projectKey + issueId + issueKey + summary + readAt + createdAt + actor { + ...UserRefFields + } +} + +query MyNotifications($unreadOnly: Boolean!, $limit: Int!) { + myNotifications(unreadOnly: $unreadOnly, limit: $limit) { + unread + items { + ...NotificationFields + } + } +} + +query NotificationSettings { + notificationSettings { + email + emailAvailable + } +} + +mutation MarkNotificationsRead($ids: [ID!]) { + markNotificationsRead(ids: $ids) +} + +mutation UpdateNotificationSettings($email: Boolean!) { + updateNotificationSettings(email: $email) { + email + emailAvailable + } +} + +subscription NotificationAdded { + notificationAdded { + ...NotificationFields + } +} 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 bf37110..7e94f14 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -7,6 +7,7 @@ import { AttachmentsGateway, CommentsGateway, IssuesGateway, + NotificationsGateway, ProjectsGateway, ReleasesGateway, TrackerRealtimeGateway, @@ -19,6 +20,7 @@ import { GraphqlUsersGateway, } from './adapters/graphql-collaboration.gateways'; import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway'; +import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway'; import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway'; import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway'; import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway'; @@ -33,6 +35,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: ActivityGateway, useClass: GraphqlActivityGateway }, { provide: AttachmentsGateway, useClass: HttpAttachmentsGateway }, { provide: UsersGateway, useClass: GraphqlUsersGateway }, + { provide: NotificationsGateway, useClass: GraphqlNotificationsGateway }, { provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway }, ]); } diff --git a/libs/web/tracker/presenters/src/index.ts b/libs/web/tracker/presenters/src/index.ts index 1aff861..10e9366 100644 --- a/libs/web/tracker/presenters/src/index.ts +++ b/libs/web/tracker/presenters/src/index.ts @@ -1 +1,2 @@ export * from './lib/tracker.routes'; +export * from './lib/ui/notifications-bell'; diff --git a/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts b/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts new file mode 100644 index 0000000..5ebfe7a --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts @@ -0,0 +1,223 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + TemplateRef, + viewChild, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { + PiActionButtonComponent, + PiBadgeComponent, + PiIconButtonComponent, + PiIconComponent, + PiSlideToggleComponent, +} from '@pi/ui'; +import { DrawerService } from '@task-stream/web/shared/ui'; +import { InboxFacade } from '@task-stream/web/tracker/application'; +import { + Notification, + NotificationKind, +} from '@task-stream/web/tracker/domain'; +import { timeAgo } from './copy'; + +const ICON: Record = { + [NotificationKind.ASSIGNED]: 'user-profile', + [NotificationKind.STATUS_CHANGED]: 'arrow-forward', + [NotificationKind.QA_FAILED]: 'exclamation', + [NotificationKind.COMMENTED]: 'chat', + [NotificationKind.MENTIONED]: 'chat', + [NotificationKind.WATCHED]: 'focus', + [NotificationKind.ADDED_TO_PROJECT]: 'briefcase', + [NotificationKind.REMINDER]: 'calendar', +}; + +/** + * The bell in the header: how many unread, and the list in a side panel. + * Opening one goes to its ticket (or project) and marks it read. + */ +@Component({ + selector: 'ts-notifications-bell', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + PiActionButtonComponent, + PiBadgeComponent, + PiIconButtonComponent, + PiIconComponent, + PiSlideToggleComponent, + ], + template: ` + + + + + +
+
+ + @if (inbox.settings()?.emailAvailable) { + + } +
+ @if (inbox.items().length) { +
    + @for (item of inbox.items(); track item.id) { +
  • + +
  • + } +
+ } @else { +

+ Nada nuevo. Te avisaremos cuando te asignen algo, comenten en tus + tickets o los muevan. +

+ } +
+
+ `, + styles: ` + :host { + display: inline-flex; + align-items: center; + } + .ts-inbox { + display: grid; + gap: 1rem; + } + .ts-inbox__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + .ts-inbox__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.25rem; + } + .ts-inbox__item { + width: 100%; + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.625rem 0.5rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + } + .ts-inbox__item:hover, + .ts-inbox__item:focus-visible { + background: rgba(127, 127, 127, 0.12); + } + .ts-inbox__item.is-unread .ts-inbox__summary { + font-weight: 600; + } + .ts-inbox__icon { + flex: none; + width: 2rem; + height: 2rem; + border-radius: 50%; + display: grid; + place-items: center; + background: rgba(127, 127, 127, 0.14); + } + .ts-inbox__text { + display: grid; + gap: 0.125rem; + flex: 1; + min-width: 0; + } + .ts-inbox__meta { + font-size: 0.8125rem; + opacity: 0.7; + } + .ts-inbox__dot { + flex: none; + width: 0.5rem; + height: 0.5rem; + margin-top: 0.5rem; + border-radius: 50%; + background: #a347e1; + } + .ts-inbox__empty { + opacity: 0.7; + } + `, +}) +export class NotificationsBell { + protected readonly inbox = inject(InboxFacade); + private readonly drawer = inject(DrawerService); + private readonly router = inject(Router); + private readonly panel = viewChild.required>('panel'); + + protected readonly ago = timeAgo; + protected icon = (item: Notification) => ICON[item.kind] ?? 'info'; + + protected open(): void { + this.drawer.open('Notificaciones', this.panel(), '28rem'); + } + + protected go(item: Notification): void { + if (!item.readAt) this.inbox.markRead([item.id]); + this.drawer.closeAll(); + const project = encodeURIComponent(item.projectKey); + void this.router.navigateByUrl( + item.issueKey + ? `/p/${project}/issues/${encodeURIComponent(item.issueKey)}` + : `/p/${project}`, + ); + } +} diff --git a/package-lock.json b/package-lock.json index 92cdc18..2f39425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "ioredis": "^5.11.1", "joi": "^18.2.9", "mongoose": "^8.24.4", + "nodemailer": "^7.0.13", "openapi-fetch": "^0.17.0", "pg": "^8.23.1", "reflect-metadata": "^0.2.0", @@ -91,6 +92,7 @@ "@swc/helpers": "~0.5.18", "@types/jest": "~30.0.0", "@types/node": "^22.0.0", + "@types/nodemailer": "^8.0.2", "@types/ws": "^8.18.2", "@typescript-eslint/utils": "^8.58.0", "@vitest/coverage-v8": "~4.1.0", @@ -14354,6 +14356,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz", + "integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "devOptional": true, @@ -25205,6 +25217,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "license": "MIT", diff --git a/package.json b/package.json index f9a782a..b9eea0c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "ioredis": "^5.11.1", "joi": "^18.2.9", "mongoose": "^8.24.4", + "nodemailer": "^7.0.13", "openapi-fetch": "^0.17.0", "pg": "^8.23.1", "reflect-metadata": "^0.2.0", @@ -87,6 +88,7 @@ "@swc/helpers": "~0.5.18", "@types/jest": "~30.0.0", "@types/node": "^22.0.0", + "@types/nodemailer": "^8.0.2", "@types/ws": "^8.18.2", "@typescript-eslint/utils": "^8.58.0", "@vitest/coverage-v8": "~4.1.0",