feat: notifications in the app and, optionally, by e-mail
Derived from events and kept per person: whoever a ticket is assigned to, whoever reports or works on it when it moves, fails QA or gets a comment, and whoever joins a project. Never about what you did yourself, once per event. They arrive live through a subscription to a bell in the header, and are copied by e-mail to whoever turns it on when SMTP_URL is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,11 @@ OPENAPI_FILE=apps/api/openapi.json
|
|||||||
ATTACHMENTS_DIR=data/attachments
|
ATTACHMENTS_DIR=data/attachments
|
||||||
ATTACHMENTS_MAX_MB=25
|
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).
|
# 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-)
|
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||||
NEXUS_TOKEN=
|
NEXUS_TOKEN=
|
||||||
|
|||||||
@@ -4707,6 +4707,363 @@
|
|||||||
],
|
],
|
||||||
"security": []
|
"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": {
|
"info": {
|
||||||
@@ -6714,6 +7071,145 @@
|
|||||||
"description": "Publish even with unfinished tickets"
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,6 +342,9 @@ type Mutation {
|
|||||||
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||||
linkIssues(input: LinkIssuesInput!): CommandResult!
|
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.
|
Ship the release. Fails while it has unfinished tickets unless force = true.
|
||||||
"""
|
"""
|
||||||
@@ -357,6 +360,7 @@ type Mutation {
|
|||||||
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
||||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
updateIssue(input: UpdateIssueInput!): CommandResult!
|
updateIssue(input: UpdateIssueInput!): CommandResult!
|
||||||
|
updateNotificationSettings(email: Boolean!): NotificationSettings!
|
||||||
updateProject(input: UpdateProjectInput!): CommandResult!
|
updateProject(input: UpdateProjectInput!): CommandResult!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -366,6 +370,48 @@ type Mutation {
|
|||||||
updateRelease(input: UpdateReleaseInput!): CommandResult!
|
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 {
|
type Project {
|
||||||
"""
|
"""
|
||||||
Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA
|
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!
|
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"""
|
"""Projects where I am a member"""
|
||||||
myProjects: [Project!]!
|
myProjects: [Project!]!
|
||||||
|
notificationSettings: NotificationSettings!
|
||||||
|
|
||||||
"""A project of mine, or any public one (no sign-in needed)"""
|
"""A project of mine, or any public one (no sign-in needed)"""
|
||||||
project(
|
project(
|
||||||
@@ -603,6 +653,9 @@ type Subscription {
|
|||||||
|
|
||||||
"""Projects I am added to / removed from"""
|
"""Projects I am added to / removed from"""
|
||||||
myProjectsChanged: ProjectChange!
|
myProjectsChanged: ProjectChange!
|
||||||
|
|
||||||
|
"""My notifications, as they happen"""
|
||||||
|
notificationAdded: Notification!
|
||||||
projectChanged(projectId: ID!): ProjectChange!
|
projectChanged(projectId: ID!): ProjectChange!
|
||||||
releaseChanged(projectId: ID!): ReleaseChange!
|
releaseChanged(projectId: ID!): ReleaseChange!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// Attached files: where they are kept (a volume in Docker) and how big one can be.
|
||||||
ATTACHMENTS_DIR: Joi.string().default('data/attachments'),
|
ATTACHMENTS_DIR: Joi.string().default('data/attachments'),
|
||||||
ATTACHMENTS_MAX_MB: Joi.number().min(1).max(500).default(25),
|
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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -703,6 +703,58 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -1336,6 +1388,50 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
force: boolean;
|
force: boolean;
|
||||||
};
|
};
|
||||||
|
Object: Record<string, never>;
|
||||||
|
/** @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;
|
responses: never;
|
||||||
parameters: 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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { filter, map } from 'rxjs';
|
|||||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
||||||
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||||
|
import { NotificationsBell } from '@task-stream/web/tracker/presenters';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-root',
|
selector: 'ts-root',
|
||||||
@@ -21,6 +22,7 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
|||||||
RouterLinkActive,
|
RouterLinkActive,
|
||||||
AppShellComponent,
|
AppShellComponent,
|
||||||
UserMenuComponent,
|
UserMenuComponent,
|
||||||
|
NotificationsBell,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<ts-app-shell>
|
<ts-app-shell>
|
||||||
@@ -44,6 +46,7 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
|||||||
Explorar
|
Explorar
|
||||||
</a>
|
</a>
|
||||||
@if (auth.isAuthenticated()) {
|
@if (auth.isAuthenticated()) {
|
||||||
|
<ts-notifications-bell shellUser />
|
||||||
<ts-user-menu shellUser />
|
<ts-user-menu shellUser />
|
||||||
} @else if (auth.settled() && !onLogin()) {
|
} @else if (auth.settled() && !onLogin()) {
|
||||||
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
|
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ services:
|
|||||||
OPENAPI_FILE: ''
|
OPENAPI_FILE: ''
|
||||||
ATTACHMENTS_DIR: /data/attachments
|
ATTACHMENTS_DIR: /data/attachments
|
||||||
ATTACHMENTS_MAX_MB: ${ATTACHMENTS_MAX_MB:-25}
|
ATTACHMENTS_MAX_MB: ${ATTACHMENTS_MAX_MB:-25}
|
||||||
|
# E-mail copies of notifications (optional).
|
||||||
|
SMTP_URL: ${SMTP_URL:-}
|
||||||
|
MAIL_FROM: ${MAIL_FROM:-}
|
||||||
|
WEB_URL: ${WEB_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Files attached to tickets: lose this volume and the tickets point at nothing.
|
# Files attached to tickets: lose this volume and the tickets point at nothing.
|
||||||
- attachments-data:/data/attachments
|
- attachments-data:/data/attachments
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
|
||||||
|
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
||||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||||
|
|||||||
@@ -149,6 +149,24 @@ stateDiagram-v2
|
|||||||
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
|
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
|
||||||
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos.
|
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos.
|
||||||
|
|
||||||
|
### Notificaciones
|
||||||
|
|
||||||
|
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
|
||||||
|
ningún agregado, solo cada uno lee y descarta las suyas.
|
||||||
|
|
||||||
|
| Cuándo | A quién | Tipo |
|
||||||
|
| ---------------------------------------- | ---------------------------------------- | ------------------ |
|
||||||
|
| Se crea un ticket asignado o se asigna | La persona asignada | `ASSIGNED` |
|
||||||
|
| Un ticket cambia de estado | Quien lo tiene asignado y quien lo abrió | `STATUS_CHANGED` |
|
||||||
|
| Un ticket vuelve de QA sin pasar a hecho | Los mismos, con el motivo | `QA_FAILED` |
|
||||||
|
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
|
||||||
|
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
|
||||||
|
|
||||||
|
Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento (la primera razón gana). El id es
|
||||||
|
`<agregado>:<versión>:<persona>`, 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
|
### Eventos de dominio
|
||||||
|
|
||||||
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
|
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` |
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `removeAttachment` (subir: por REST) |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `removeAttachment` (subir: por REST) |
|
||||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||||
|
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
||||||
|
|
||||||
### Subscriptions
|
### 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) |
|
| 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` |
|
| 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}` |
|
| 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}` |
|
| 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` |
|
| 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) |
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ donde los lee Compose.
|
|||||||
## 7.2 Variables de entorno de la API
|
## 7.2 Variables de entorno de la API
|
||||||
|
|
||||||
| Variable | Por defecto | Descripción |
|
| Variable | Por defecto | Descripción |
|
||||||
| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- |
|
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| `PORT` | `3000` | Puerto HTTP |
|
| `PORT` | `3000` | Puerto HTTP |
|
||||||
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
||||||
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
||||||
@@ -94,6 +94,9 @@ donde los lee Compose.
|
|||||||
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
|
| `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) |
|
| `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_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 <no-reply@pireon.dev>` | 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) |
|
| `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`).
|
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
|
||||||
|
|||||||
@@ -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
|
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.
|
`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.
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ export * from './lib/comments.service';
|
|||||||
export * from './lib/releases.service';
|
export * from './lib/releases.service';
|
||||||
export * from './lib/changes.service';
|
export * from './lib/changes.service';
|
||||||
export * from './lib/workflows.service';
|
export * from './lib/workflows.service';
|
||||||
|
export * from './lib/notifications.service';
|
||||||
|
export * from './lib/notifications';
|
||||||
export * from './lib/tracker-application.module';
|
export * from './lib/tracker-application.module';
|
||||||
|
|||||||
@@ -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<MyNotifications> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new GetMyNotificationsQuery(
|
||||||
|
userId,
|
||||||
|
options.unreadOnly ?? false,
|
||||||
|
options.limit ?? 30,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ids` omitted: everything. */
|
||||||
|
markRead(userId: string, ids?: string[] | null): Promise<void> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings(userId: string): Promise<MyNotificationSettings> {
|
||||||
|
return this.queryBus.execute(new GetMyNotificationSettingsQuery(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSettings(
|
||||||
|
userId: string,
|
||||||
|
changes: { email: boolean },
|
||||||
|
): Promise<MyNotificationSettings> {
|
||||||
|
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<NotificationReadModel> {
|
||||||
|
return this.changeFeed.notifications(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './notifier';
|
||||||
|
export * from './notifications.queries';
|
||||||
|
export * from './notifications.commands';
|
||||||
@@ -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<void> {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
/** Null: all of them. */
|
||||||
|
public readonly ids: string[] | null,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateNotificationSettingsCommand extends Command<void> {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly email: boolean,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(MarkNotificationsReadCommand)
|
||||||
|
export class MarkNotificationsReadCommandHandler
|
||||||
|
implements ICommandHandler<MarkNotificationsReadCommand>
|
||||||
|
{
|
||||||
|
constructor(private readonly notifications: NotificationsRepository) {}
|
||||||
|
|
||||||
|
async execute(command: MarkNotificationsReadCommand): Promise<void> {
|
||||||
|
await this.notifications.markRead(command.userId, command.ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(UpdateNotificationSettingsCommand)
|
||||||
|
export class UpdateNotificationSettingsCommandHandler
|
||||||
|
implements ICommandHandler<UpdateNotificationSettingsCommand>
|
||||||
|
{
|
||||||
|
constructor(private readonly notifications: NotificationsRepository) {}
|
||||||
|
|
||||||
|
async execute(command: UpdateNotificationSettingsCommand): Promise<void> {
|
||||||
|
await this.notifications.saveSettings({
|
||||||
|
userId: command.userId,
|
||||||
|
email: command.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TrackerEvent> {
|
||||||
|
constructor(
|
||||||
|
private readonly notifier: Notifier,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly findProjects: FindProjectsRepository,
|
||||||
|
private readonly users: UserDirectory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: TrackerEvent): Promise<void> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MyNotifications> {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly unreadOnly: boolean,
|
||||||
|
public readonly limit: number,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetMyNotificationSettingsQuery extends Query<MyNotificationSettings> {
|
||||||
|
constructor(public readonly userId: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetMyNotificationsQuery)
|
||||||
|
export class GetMyNotificationsQueryHandler
|
||||||
|
implements IQueryHandler<GetMyNotificationsQuery, MyNotifications>
|
||||||
|
{
|
||||||
|
constructor(private readonly notifications: NotificationsRepository) {}
|
||||||
|
|
||||||
|
async execute(query: GetMyNotificationsQuery): Promise<MyNotifications> {
|
||||||
|
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<GetMyNotificationSettingsQuery, MyNotificationSettings>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly notifications: NotificationsRepository,
|
||||||
|
private readonly mailer: NotificationMailer,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
query: GetMyNotificationSettingsQuery,
|
||||||
|
): Promise<MyNotificationSettings> {
|
||||||
|
const settings = await this.notifications.settings(query.userId);
|
||||||
|
return {
|
||||||
|
email: this.mailer.enabled && settings.email,
|
||||||
|
emailAvailable: this.mailer.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
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<NotificationSettingsReadModel> {
|
||||||
|
return { userId, email: this.emailOn.has(userId) };
|
||||||
|
}
|
||||||
|
async saveSettings() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = <T extends TrackerEvent>(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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
110
libs/api/tracker/application/src/lib/notifications/notifier.ts
Normal file
110
libs/api/tracker/application/src/lib/notifications/notifier.ts
Normal file
@@ -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<void> {
|
||||||
|
const seen = new Set<string>([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<void> {
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ActivityReadModel,
|
ActivityReadModel,
|
||||||
CommentReadModel,
|
CommentReadModel,
|
||||||
|
NotificationReadModel,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
ProjectReadModel,
|
ProjectReadModel,
|
||||||
ReleaseReadModel,
|
ReleaseReadModel,
|
||||||
@@ -48,6 +49,10 @@ export abstract class ChangeFeed {
|
|||||||
notifyUserIds?: string[],
|
notifyUserIds?: string[],
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
|
abstract publishNotification(
|
||||||
|
notification: NotificationReadModel,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
|
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
|
||||||
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
|
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
|
||||||
abstract activity(
|
abstract activity(
|
||||||
@@ -56,4 +61,7 @@ export abstract class ChangeFeed {
|
|||||||
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
|
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
|
||||||
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
|
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
|
||||||
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
|
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
|
||||||
|
abstract notifications(
|
||||||
|
userId: string,
|
||||||
|
): AsyncIterableIterator<NotificationReadModel>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ export * from './upsert-materialized-timeline.repository';
|
|||||||
export * from './find-timeline.repository';
|
export * from './find-timeline.repository';
|
||||||
export * from './tracker-streams.repository';
|
export * from './tracker-streams.repository';
|
||||||
export * from './attachment-storage';
|
export * from './attachment-storage';
|
||||||
|
export * from './notifications.repository';
|
||||||
|
export * from './notification-mailer';
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
@@ -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<boolean>;
|
||||||
|
/** Newest first. */
|
||||||
|
abstract findForUser(
|
||||||
|
userId: string,
|
||||||
|
options: { unreadOnly: boolean; limit: number },
|
||||||
|
): Promise<NotificationReadModel[]>;
|
||||||
|
abstract countUnread(userId: string): Promise<number>;
|
||||||
|
/** `ids` null: all of them. */
|
||||||
|
abstract markRead(userId: string, ids: string[] | null): Promise<void>;
|
||||||
|
abstract settings(userId: string): Promise<NotificationSettingsReadModel>;
|
||||||
|
abstract saveSettings(settings: NotificationSettingsReadModel): Promise<void>;
|
||||||
|
}
|
||||||
@@ -6,6 +6,12 @@ export interface DirectoryUser {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserContact {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Outbound port to the identity context. */
|
/** Outbound port to the identity context. */
|
||||||
export abstract class UserDirectory {
|
export abstract class UserDirectory {
|
||||||
/**
|
/**
|
||||||
@@ -17,6 +23,9 @@ export abstract class UserDirectory {
|
|||||||
accessToken?: string,
|
accessToken?: string,
|
||||||
): Promise<DirectoryUser | null>;
|
): Promise<DirectoryUser | null>;
|
||||||
|
|
||||||
|
/** Names and e-mail addresses, for the users this application knows. */
|
||||||
|
abstract contacts(userIds: string[]): Promise<UserContact[]>;
|
||||||
|
|
||||||
/** Resolves user ids to {id, name} for read models, in one lookup. */
|
/** Resolves user ids to {id, name} for read models, in one lookup. */
|
||||||
abstract refs(userIds: (string | null | undefined)[]): Promise<UserRefs>;
|
abstract refs(userIds: (string | null | undefined)[]): Promise<UserRefs>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ import { ReadAccess } from './queries/read-access';
|
|||||||
import { ReleasesService } from './releases.service';
|
import { ReleasesService } from './releases.service';
|
||||||
import { SAGAS } from './sagas/sagas';
|
import { SAGAS } from './sagas/sagas';
|
||||||
import { WorkflowsService } from './workflows.service';
|
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 = [
|
const SERVICES = [
|
||||||
ProjectsService,
|
ProjectsService,
|
||||||
@@ -24,6 +45,7 @@ const SERVICES = [
|
|||||||
ReleasesService,
|
ReleasesService,
|
||||||
ChangesService,
|
ChangesService,
|
||||||
WorkflowsService,
|
WorkflowsService,
|
||||||
|
NotificationsService,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +65,7 @@ const SERVICES = [
|
|||||||
...QUERY_HANDLERS,
|
...QUERY_HANDLERS,
|
||||||
...EVENT_HANDLERS,
|
...EVENT_HANDLERS,
|
||||||
...SAGAS,
|
...SAGAS,
|
||||||
|
...NOTIFICATIONS,
|
||||||
],
|
],
|
||||||
exports: SERVICES,
|
exports: SERVICES,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -170,6 +170,16 @@ export class FakeUserDirectory extends UserDirectory {
|
|||||||
return this.users.get(userId) ?? null;
|
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<UserRefs> {
|
async refs(): Promise<UserRefs> {
|
||||||
const of = (id: string) => ({ id, name: this.users.get(id)?.name ?? id });
|
const of = (id: string) => ({ id, name: this.users.get(id)?.name ?? id });
|
||||||
return { of, ofNullable: (id) => (id ? of(id) : null) };
|
return { of, ofNullable: (id) => (id ? of(id) : null) };
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from './release.read-model';
|
|||||||
export * from './activity.read-model';
|
export * from './activity.read-model';
|
||||||
export * from './project-stats.read-model';
|
export * from './project-stats.read-model';
|
||||||
export * from './analytics.read-model';
|
export * from './analytics.read-model';
|
||||||
|
export * from './notification.read-model';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
/** `<aggregateId>:<version>:<userId>`: 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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@task-stream/api/identity/application';
|
} from '@task-stream/api/identity/application';
|
||||||
import {
|
import {
|
||||||
DirectoryUser,
|
DirectoryUser,
|
||||||
|
UserContact,
|
||||||
UserDirectory,
|
UserDirectory,
|
||||||
UserRefs,
|
UserRefs,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
@@ -29,6 +30,17 @@ export class IdentityUserDirectory extends UserDirectory {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async contacts(userIds: string[]): Promise<UserContact[]> {
|
||||||
|
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<UserRefs> {
|
async refs(userIds: (string | null | undefined)[]): Promise<UserRefs> {
|
||||||
const wanted = [...new Set(userIds.filter((id): id is string => !!id))];
|
const wanted = [...new Set(userIds.filter((id): id is string => !!id))];
|
||||||
const users = wanted.length
|
const users = wanted.length
|
||||||
|
|||||||
@@ -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<string>('SMTP_URL');
|
||||||
|
this.transport = url ? createTransport(url) : null;
|
||||||
|
this.from =
|
||||||
|
config.get<string>('MAIL_FROM') || 'TaskStream <no-reply@pireon.dev>';
|
||||||
|
this.webUrl = (config.get<string>('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<void> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NotificationDoc>,
|
||||||
|
@InjectModel(ReadModels.NotificationSettings)
|
||||||
|
private readonly settingsDocs: Model<NotificationSettingsDoc>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async insert({ id, ...rest }: NotificationReadModel): Promise<boolean> {
|
||||||
|
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<NotificationReadModel[]> {
|
||||||
|
const docs = await this.notifications
|
||||||
|
.find({ userId, ...(options.unreadOnly ? { readAt: null } : {}) })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(options.limit)
|
||||||
|
.lean<NotificationDoc[]>();
|
||||||
|
return docs.map((doc) => fromDoc(doc) as NotificationReadModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
countUnread(userId: string): Promise<number> {
|
||||||
|
return this.notifications.countDocuments({ userId, readAt: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(userId: string, ids: string[] | null): Promise<void> {
|
||||||
|
await this.notifications.updateMany(
|
||||||
|
{ userId, readAt: null, ...(ids ? { _id: { $in: ids } } : {}) },
|
||||||
|
{ $set: { readAt: new Date().toISOString() } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async settings(userId: string): Promise<NotificationSettingsReadModel> {
|
||||||
|
const doc = await this.settingsDocs
|
||||||
|
.findById(userId)
|
||||||
|
.lean<NotificationSettingsDoc>();
|
||||||
|
return { userId, email: doc?.email ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings(settings: NotificationSettingsReadModel): Promise<void> {
|
||||||
|
await this.settingsDocs.updateOne(
|
||||||
|
{ _id: settings.userId },
|
||||||
|
{ $set: { email: settings.email } },
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ActivityReadModel,
|
ActivityReadModel,
|
||||||
CommentReadModel,
|
CommentReadModel,
|
||||||
|
NotificationReadModel,
|
||||||
IssueLinkReadModel,
|
IssueLinkReadModel,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
IssueTimelineEntry,
|
IssueTimelineEntry,
|
||||||
@@ -23,6 +24,11 @@ export type CommentDoc = Doc<CommentReadModel>;
|
|||||||
export type ReleaseDoc = Doc<ReleaseReadModel>;
|
export type ReleaseDoc = Doc<ReleaseReadModel>;
|
||||||
export type ActivityDoc = Doc<ActivityReadModel>;
|
export type ActivityDoc = Doc<ActivityReadModel>;
|
||||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||||
|
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||||
|
export interface NotificationSettingsDoc {
|
||||||
|
_id: string;
|
||||||
|
email: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const ReadModels = {
|
export const ReadModels = {
|
||||||
Project: 'ProjectReadModel',
|
Project: 'ProjectReadModel',
|
||||||
@@ -31,6 +37,8 @@ export const ReadModels = {
|
|||||||
Release: 'ReleaseReadModel',
|
Release: 'ReleaseReadModel',
|
||||||
Activity: 'ActivityReadModel',
|
Activity: 'ActivityReadModel',
|
||||||
Timeline: 'IssueTimelineReadModel',
|
Timeline: 'IssueTimelineReadModel',
|
||||||
|
Notification: 'NotificationReadModel',
|
||||||
|
NotificationSettings: 'NotificationSettings',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const userRef = { _id: false, id: String, name: String };
|
const userRef = { _id: false, id: String, name: String };
|
||||||
@@ -127,6 +135,24 @@ export const TimelineSchema = new Schema<TimelineDoc>(
|
|||||||
);
|
);
|
||||||
TimelineSchema.index({ projectId: 1, at: 1 });
|
TimelineSchema.index({ projectId: 1, at: 1 });
|
||||||
|
|
||||||
|
export const NotificationSchema = new Schema<NotificationDoc>(
|
||||||
|
{
|
||||||
|
_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<NotificationSettingsDoc>(
|
||||||
|
{ _id: String, email: Boolean },
|
||||||
|
{ ...loose, collection: 'notification_settings' },
|
||||||
|
);
|
||||||
|
|
||||||
export const READ_MODEL_SCHEMAS = [
|
export const READ_MODEL_SCHEMAS = [
|
||||||
{ name: ReadModels.Project, schema: ProjectSchema },
|
{ name: ReadModels.Project, schema: ProjectSchema },
|
||||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||||
@@ -134,6 +160,8 @@ export const READ_MODEL_SCHEMAS = [
|
|||||||
{ name: ReadModels.Release, schema: ReleaseSchema },
|
{ name: ReadModels.Release, schema: ReleaseSchema },
|
||||||
{ name: ReadModels.Activity, schema: ActivitySchema },
|
{ name: ReadModels.Activity, schema: ActivitySchema },
|
||||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||||
|
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||||
|
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function fromDoc<T extends { _id: string }>(
|
export function fromDoc<T extends { _id: string }>(
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
ProjectChange,
|
ProjectChange,
|
||||||
ReleaseChange,
|
ReleaseChange,
|
||||||
} from '@task-stream/api/tracker/application';
|
} 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';
|
import { RedisPubSub } from 'graphql-redis-subscriptions';
|
||||||
|
|
||||||
/** Redis pub/sub channels (one per project / issue / user). Works across instances. */
|
/** Redis pub/sub channels (one per project / issue / user). Works across instances. */
|
||||||
@@ -18,6 +21,7 @@ const Channels = {
|
|||||||
release: (projectId: string) => `tracker.release.${projectId}`,
|
release: (projectId: string) => `tracker.release.${projectId}`,
|
||||||
project: (projectId: string) => `tracker.project.${projectId}`,
|
project: (projectId: string) => `tracker.project.${projectId}`,
|
||||||
userProjects: (userId: string) => `tracker.user-projects.${userId}`,
|
userProjects: (userId: string) => `tracker.user-projects.${userId}`,
|
||||||
|
notifications: (userId: string) => `tracker.notifications.${userId}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -57,6 +61,17 @@ export class RedisChangeFeed extends ChangeFeed {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publishNotification(notification: NotificationReadModel): Promise<void> {
|
||||||
|
return this.pubSub.publish(
|
||||||
|
Channels.notifications(notification.userId),
|
||||||
|
notification,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications(userId: string): AsyncIterableIterator<NotificationReadModel> {
|
||||||
|
return this.pubSub.asyncIterableIterator(Channels.notifications(userId));
|
||||||
|
}
|
||||||
|
|
||||||
issues(projectId: string): AsyncIterableIterator<IssueChange> {
|
issues(projectId: string): AsyncIterableIterator<IssueChange> {
|
||||||
return this.pubSub.asyncIterableIterator(Channels.issue(projectId));
|
return this.pubSub.asyncIterableIterator(Channels.issue(projectId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import {
|
import {
|
||||||
AttachmentStorage,
|
AttachmentStorage,
|
||||||
ChangeFeed,
|
ChangeFeed,
|
||||||
|
NotificationMailer,
|
||||||
|
NotificationsRepository,
|
||||||
FindActivityRepository,
|
FindActivityRepository,
|
||||||
FindCommentsRepository,
|
FindCommentsRepository,
|
||||||
FindIssuesRepository,
|
FindIssuesRepository,
|
||||||
@@ -52,6 +54,8 @@ import {
|
|||||||
import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository';
|
import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository';
|
||||||
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
||||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
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';
|
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
|
||||||
|
|
||||||
const PORTS = [
|
const PORTS = [
|
||||||
@@ -100,6 +104,9 @@ const PORTS = [
|
|||||||
},
|
},
|
||||||
// Attached files → disk (ATTACHMENTS_DIR)
|
// Attached files → disk (ATTACHMENTS_DIR)
|
||||||
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
|
{ 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
|
// Realtime → Redis pub/sub
|
||||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||||
// Identity context (anti-corruption layer)
|
// Identity context (anti-corruption layer)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { registerEnumType } from '@nestjs/graphql';
|
|||||||
import {
|
import {
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
|
NotificationKind,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
@@ -22,3 +23,7 @@ registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
|
|||||||
registerEnumType(ProjectRole, { name: 'ProjectRole' });
|
registerEnumType(ProjectRole, { name: 'ProjectRole' });
|
||||||
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
|
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
|
||||||
registerEnumType(LinkDirection, { name: 'LinkDirection' });
|
registerEnumType(LinkDirection, { name: 'LinkDirection' });
|
||||||
|
registerEnumType(NotificationKind, {
|
||||||
|
name: 'NotificationKind',
|
||||||
|
description: 'Why someone is told',
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Args, ID, Resolver, Subscription } from '@nestjs/graphql';
|
import { Args, ID, Resolver, Subscription } from '@nestjs/graphql';
|
||||||
import { User } from '@task-stream/api/identity/domain';
|
import { User } from '@task-stream/api/identity/domain';
|
||||||
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
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 {
|
import {
|
||||||
ActivityGql,
|
ActivityGql,
|
||||||
CommentChangeGql,
|
CommentChangeGql,
|
||||||
IssueChangeGql,
|
IssueChangeGql,
|
||||||
|
NotificationGql,
|
||||||
ProjectChangeGql,
|
ProjectChangeGql,
|
||||||
ReleaseChangeGql,
|
ReleaseChangeGql,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -18,7 +22,10 @@ const asIs = { resolve: <T>(payload: T) => payload };
|
|||||||
*/
|
*/
|
||||||
@Resolver()
|
@Resolver()
|
||||||
export class SubscriptionResolver {
|
export class SubscriptionResolver {
|
||||||
constructor(private readonly changes: ChangesService) {}
|
constructor(
|
||||||
|
private readonly changes: ChangesService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Subscription(() => IssueChangeGql, {
|
@Subscription(() => IssueChangeGql, {
|
||||||
...asIs,
|
...asIs,
|
||||||
@@ -74,4 +81,12 @@ export class SubscriptionResolver {
|
|||||||
myProjectsChanged(@CurrentUser() me: User) {
|
myProjectsChanged(@CurrentUser() me: User) {
|
||||||
return this.changes.watchMyProjects(me.id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql';
|
|||||||
import {
|
import {
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
|
NotificationKind,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
@@ -242,6 +243,37 @@ export class ActivityGql {
|
|||||||
@Field() occurredAt!: string;
|
@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', {
|
@ObjectType('CommandResult', {
|
||||||
description:
|
description:
|
||||||
'Writes are acknowledged immediately; the read model catches up asynchronously. ' +
|
'Writes are acknowledged immediately; the read model catches up asynchronously. ' +
|
||||||
|
|||||||
@@ -637,3 +637,39 @@ export class PublishReleaseDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
force = false;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import {
|
import {
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
|
NotificationKind,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
SETTING_COLORS,
|
SETTING_COLORS,
|
||||||
@@ -354,3 +355,40 @@ export class ReleaseBurnupDto {
|
|||||||
scope!: number[];
|
scope!: number[];
|
||||||
@ApiProperty({ type: [Number] }) done!: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { MulterModule } from '@nestjs/platform-express';
|
import { MulterModule } from '@nestjs/platform-express';
|
||||||
import './graphql/enums';
|
import './graphql/enums';
|
||||||
import { CommentResolver } from './graphql/comment.resolver';
|
import { CommentResolver } from './graphql/comment.resolver';
|
||||||
|
import { NotificationResolver } from './graphql/notification.resolver';
|
||||||
import { IssueResolver } from './graphql/issue.resolver';
|
import { IssueResolver } from './graphql/issue.resolver';
|
||||||
import { ProjectResolver } from './graphql/project.resolver';
|
import { ProjectResolver } from './graphql/project.resolver';
|
||||||
import { ReleaseResolver } from './graphql/release.resolver';
|
import { ReleaseResolver } from './graphql/release.resolver';
|
||||||
@@ -14,6 +15,7 @@ import { ExploreController } from './http/explore.controller';
|
|||||||
import { ProjectsController } from './http/projects.controller';
|
import { ProjectsController } from './http/projects.controller';
|
||||||
import { Refs } from './http/refs';
|
import { Refs } from './http/refs';
|
||||||
import { ReleasesController } from './http/releases.controller';
|
import { ReleasesController } from './http/releases.controller';
|
||||||
|
import { NotificationsController } from './http/notifications.controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||||
@@ -42,6 +44,7 @@ import { ReleasesController } from './http/releases.controller';
|
|||||||
CommentsController,
|
CommentsController,
|
||||||
ReleasesController,
|
ReleasesController,
|
||||||
ExploreController,
|
ExploreController,
|
||||||
|
NotificationsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
Refs,
|
Refs,
|
||||||
@@ -50,6 +53,7 @@ import { ReleasesController } from './http/releases.controller';
|
|||||||
CommentResolver,
|
CommentResolver,
|
||||||
ReleaseResolver,
|
ReleaseResolver,
|
||||||
SubscriptionResolver,
|
SubscriptionResolver,
|
||||||
|
NotificationResolver,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TrackerPresentersModule {
|
export class TrackerPresentersModule {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { MatIconRegistry } from '@angular/material/icon';
|
|||||||
export const APP_ICONS: Record<string, string> = {
|
export const APP_ICONS: Record<string, string> = {
|
||||||
'arrow-forward':
|
'arrow-forward':
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h15M13 6l6 6-6 6"/></svg>',
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h15M13 6l6 6-6 6"/></svg>',
|
||||||
|
bell: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15L6 16ZM10 20.5a2 2 0 0 0 4 0"/></svg>',
|
||||||
briefcase:
|
briefcase:
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M3 12h18"/></svg>',
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M3 12h18"/></svg>',
|
||||||
buildings:
|
buildings:
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from './lib/facades/board.facade';
|
|||||||
export * from './lib/facades/issue.facade';
|
export * from './lib/facades/issue.facade';
|
||||||
export * from './lib/facades/home.facade';
|
export * from './lib/facades/home.facade';
|
||||||
export * from './lib/facades/explore.facade';
|
export * from './lib/facades/explore.facade';
|
||||||
|
export * from './lib/facades/inbox.facade';
|
||||||
|
|||||||
37
libs/web/tracker/application/src/lib/events/inbox.events.ts
Normal file
37
libs/web/tracker/application/src/lib/events/inbox.events.ts
Normal file
@@ -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<void>(),
|
||||||
|
/** Signed out: forget everything. */
|
||||||
|
closed: type<void>(),
|
||||||
|
markReadRequested: type<{ ids?: string[] }>(),
|
||||||
|
emailToggled: type<boolean>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const inboxApiEvents = eventGroup({
|
||||||
|
source: 'Inbox API',
|
||||||
|
events: {
|
||||||
|
loaded: type<{ items: Notification[]; unread: number }>(),
|
||||||
|
settingsLoaded: type<NotificationSettings>(),
|
||||||
|
markedRead: type<{ ids?: string[]; at: string }>(),
|
||||||
|
failed: type<string>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const inboxRealtimeEvents = eventGroup({
|
||||||
|
source: 'Inbox Realtime',
|
||||||
|
events: {
|
||||||
|
arrived: type<Notification>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AttachmentsGateway,
|
AttachmentsGateway,
|
||||||
CommentsGateway,
|
CommentsGateway,
|
||||||
IssuesGateway,
|
IssuesGateway,
|
||||||
|
NotificationsGateway,
|
||||||
Project,
|
Project,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
@@ -21,6 +22,7 @@ import { projectPageEvents } from '../events/project.events';
|
|||||||
import { Notifications } from '../notifications';
|
import { Notifications } from '../notifications';
|
||||||
import { BoardFacade, provideBoardFacade } from './board.facade';
|
import { BoardFacade, provideBoardFacade } from './board.facade';
|
||||||
import { HomeFacade } from './home.facade';
|
import { HomeFacade } from './home.facade';
|
||||||
|
import { InboxFacade } from './inbox.facade';
|
||||||
import { IssueFacade, provideIssueFacade } from './issue.facade';
|
import { IssueFacade, provideIssueFacade } from './issue.facade';
|
||||||
import { ProjectFacade, provideProjectFacade } from './project.facade';
|
import { ProjectFacade, provideProjectFacade } from './project.facade';
|
||||||
|
|
||||||
@@ -307,3 +309,84 @@ describe('HomeFacade', () => {
|
|||||||
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
|
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<never>();
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
37
libs/web/tracker/application/src/lib/facades/inbox.facade.ts
Normal file
37
libs/web/tracker/application/src/lib/facades/inbox.facade.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
libs/web/tracker/application/src/lib/stores/inbox.store.ts
Normal file
143
libs/web/tracker/application/src/lib/stores/inbox.store.ts
Normal file
@@ -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<InboxState>({ 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))),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -38,3 +38,15 @@ export enum ReleaseStatus {
|
|||||||
RELEASED = 'RELEASED',
|
RELEASED = 'RELEASED',
|
||||||
ARCHIVED = 'ARCHIVED',
|
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',
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
NotificationKind,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
@@ -187,3 +188,25 @@ export interface CommandResult {
|
|||||||
key?: string | null;
|
key?: string | null;
|
||||||
version: number;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Issue,
|
Issue,
|
||||||
IssuePage,
|
IssuePage,
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
|
Notification,
|
||||||
|
NotificationSettings,
|
||||||
Project,
|
Project,
|
||||||
ProjectAnalytics,
|
ProjectAnalytics,
|
||||||
ProjectStats,
|
ProjectStats,
|
||||||
@@ -130,6 +132,18 @@ export abstract class UsersGateway {
|
|||||||
abstract search(term: string): Observable<UserDirectoryEntry[]>;
|
abstract search(term: string): Observable<UserDirectoryEntry[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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<void>;
|
||||||
|
abstract settings(): Observable<NotificationSettings>;
|
||||||
|
abstract updateSettings(email: boolean): Observable<NotificationSettings>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Realtime (GraphQL subscriptions over WebSocket). */
|
/** Realtime (GraphQL subscriptions over WebSocket). */
|
||||||
export interface IssueChanged {
|
export interface IssueChanged {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -158,4 +172,5 @@ export abstract class TrackerRealtimeGateway {
|
|||||||
abstract releaseChanges(projectId: string): Observable<Release>;
|
abstract releaseChanges(projectId: string): Observable<Release>;
|
||||||
abstract projectChanges(projectId: string): Observable<ProjectChanged>;
|
abstract projectChanges(projectId: string): Observable<ProjectChanged>;
|
||||||
abstract myProjectsChanges(): Observable<ProjectChanged>;
|
abstract myProjectsChanges(): Observable<ProjectChanged>;
|
||||||
|
abstract notifications(): Observable<Notification>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(MarkNotificationsReadDocument, { ids: ids ?? null })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
settings(): Observable<NotificationSettings> {
|
||||||
|
return this.gql
|
||||||
|
.query(NotificationSettingsDocument)
|
||||||
|
.pipe(map((d) => ({ ...d.notificationSettings })));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(email: boolean): Observable<NotificationSettings> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(UpdateNotificationSettingsDocument, { email })
|
||||||
|
.pipe(map((d) => ({ ...d.updateNotificationSettings })));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
CommentChanged,
|
CommentChanged,
|
||||||
IssueChanged,
|
IssueChanged,
|
||||||
|
Notification,
|
||||||
ProjectChanged,
|
ProjectChanged,
|
||||||
Release,
|
Release,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
CommentChangedDocument,
|
CommentChangedDocument,
|
||||||
IssueChangedDocument,
|
IssueChangedDocument,
|
||||||
MyProjectsChangedDocument,
|
MyProjectsChangedDocument,
|
||||||
|
NotificationAddedDocument,
|
||||||
ProjectChangedDocument,
|
ProjectChangedDocument,
|
||||||
ReleaseChangedDocument,
|
ReleaseChangedDocument,
|
||||||
} from '../graphql/generated';
|
} from '../graphql/generated';
|
||||||
@@ -98,6 +100,14 @@ export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
|
|||||||
.pipe(map((d) => toProjectChanged(d.myProjectsChanged))),
|
.pipe(map((d) => toProjectChanged(d.myProjectsChanged))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifications(): Observable<Notification> {
|
||||||
|
return this.signedIn(() =>
|
||||||
|
this.gql
|
||||||
|
.subscribe(NotificationAddedDocument)
|
||||||
|
.pipe(map((d) => d.notificationAdded as Notification)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toProjectChanged = (change: {
|
const toProjectChanged = (change: {
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ export type LinkIssuesInput = {
|
|||||||
type: IssueLinkType;
|
type: IssueLinkType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Why someone is told */
|
||||||
|
export type NotificationKind =
|
||||||
|
| 'ADDED_TO_PROJECT'
|
||||||
|
| 'ASSIGNED'
|
||||||
|
| 'COMMENTED'
|
||||||
|
| 'MENTIONED'
|
||||||
|
| 'QA_FAILED'
|
||||||
|
| 'REMINDER'
|
||||||
|
| 'STATUS_CHANGED'
|
||||||
|
| 'WATCHED';
|
||||||
|
|
||||||
export type ProjectMemberInput = {
|
export type ProjectMemberInput = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
role?: ProjectRole;
|
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> | 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 MyProjectsQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
export type MyProjectsQuery = {
|
export type MyProjectsQuery = {
|
||||||
@@ -1559,6 +1647,61 @@ export const CommandResultFieldsFragmentDoc = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<CommandResultFieldsFragment, unknown>;
|
} as unknown as DocumentNode<CommandResultFieldsFragment, unknown>;
|
||||||
|
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<NotificationFieldsFragment, unknown>;
|
||||||
export const BoardDocument = {
|
export const BoardDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -3155,6 +3298,358 @@ export const MyIssuesDocument = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<MyIssuesQuery, MyIssuesQueryVariables>;
|
} as unknown as DocumentNode<MyIssuesQuery, MyIssuesQueryVariables>;
|
||||||
|
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 = {
|
export const MyProjectsDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AttachmentsGateway,
|
AttachmentsGateway,
|
||||||
CommentsGateway,
|
CommentsGateway,
|
||||||
IssuesGateway,
|
IssuesGateway,
|
||||||
|
NotificationsGateway,
|
||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
ReleasesGateway,
|
ReleasesGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
GraphqlUsersGateway,
|
GraphqlUsersGateway,
|
||||||
} from './adapters/graphql-collaboration.gateways';
|
} from './adapters/graphql-collaboration.gateways';
|
||||||
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
||||||
|
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
|
||||||
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
||||||
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||||
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
||||||
@@ -33,6 +35,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
|||||||
{ provide: ActivityGateway, useClass: GraphqlActivityGateway },
|
{ provide: ActivityGateway, useClass: GraphqlActivityGateway },
|
||||||
{ provide: AttachmentsGateway, useClass: HttpAttachmentsGateway },
|
{ provide: AttachmentsGateway, useClass: HttpAttachmentsGateway },
|
||||||
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
||||||
|
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
||||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './lib/tracker.routes';
|
export * from './lib/tracker.routes';
|
||||||
|
export * from './lib/ui/notifications-bell';
|
||||||
|
|||||||
223
libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts
Normal file
223
libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts
Normal file
@@ -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, string> = {
|
||||||
|
[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: `
|
||||||
|
<pi-badge
|
||||||
|
[value]="inbox.unread()"
|
||||||
|
[max]="99"
|
||||||
|
color="warn"
|
||||||
|
size="small"
|
||||||
|
[isHidden]="!inbox.hasUnread()"
|
||||||
|
>
|
||||||
|
<pi-icon-button
|
||||||
|
icon="bell"
|
||||||
|
color="grey"
|
||||||
|
appearance="ghost"
|
||||||
|
[attr.aria-label]="
|
||||||
|
inbox.unread()
|
||||||
|
? 'Notificaciones: ' + inbox.unread() + ' sin leer'
|
||||||
|
: 'Notificaciones'
|
||||||
|
"
|
||||||
|
(execute)="open()"
|
||||||
|
/>
|
||||||
|
</pi-badge>
|
||||||
|
|
||||||
|
<ng-template #panel>
|
||||||
|
<div class="ts-inbox">
|
||||||
|
<div class="ts-inbox__actions">
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="grey"
|
||||||
|
appearance="outline"
|
||||||
|
text="Marcar todo como leído"
|
||||||
|
[disabled]="!inbox.hasUnread()"
|
||||||
|
(execute)="inbox.markRead()"
|
||||||
|
/>
|
||||||
|
@if (inbox.settings()?.emailAvailable) {
|
||||||
|
<pi-slide-toggle
|
||||||
|
label="Copia por correo"
|
||||||
|
[checked]="inbox.settings()?.email ?? false"
|
||||||
|
(checkedChange)="inbox.setEmail($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (inbox.items().length) {
|
||||||
|
<ul class="ts-inbox__list">
|
||||||
|
@for (item of inbox.items(); track item.id) {
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-inbox__item"
|
||||||
|
[class.is-unread]="!item.readAt"
|
||||||
|
(click)="go(item)"
|
||||||
|
>
|
||||||
|
<span class="ts-inbox__icon" aria-hidden="true">
|
||||||
|
<pi-icon [icon]="icon(item)" size="small" />
|
||||||
|
</span>
|
||||||
|
<span class="ts-inbox__text">
|
||||||
|
<span class="ts-inbox__summary">{{ item.summary }}</span>
|
||||||
|
<span class="ts-inbox__meta"
|
||||||
|
>{{ item.projectKey }} · {{ ago(item.createdAt) }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
@if (!item.readAt) {
|
||||||
|
<span class="ts-inbox__dot" aria-label="Sin leer"></span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="ts-inbox__empty">
|
||||||
|
Nada nuevo. Te avisaremos cuando te asignen algo, comenten en tus
|
||||||
|
tickets o los muevan.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
`,
|
||||||
|
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<TemplateRef<unknown>>('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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -49,6 +49,7 @@
|
|||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"joi": "^18.2.9",
|
"joi": "^18.2.9",
|
||||||
"mongoose": "^8.24.4",
|
"mongoose": "^8.24.4",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
"@swc/helpers": "~0.5.18",
|
"@swc/helpers": "~0.5.18",
|
||||||
"@types/jest": "~30.0.0",
|
"@types/jest": "~30.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
"@types/ws": "^8.18.2",
|
"@types/ws": "^8.18.2",
|
||||||
"@typescript-eslint/utils": "^8.58.0",
|
"@typescript-eslint/utils": "^8.58.0",
|
||||||
"@vitest/coverage-v8": "~4.1.0",
|
"@vitest/coverage-v8": "~4.1.0",
|
||||||
@@ -14354,6 +14356,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/parse-json": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
@@ -25205,6 +25217,15 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"joi": "^18.2.9",
|
"joi": "^18.2.9",
|
||||||
"mongoose": "^8.24.4",
|
"mongoose": "^8.24.4",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
@@ -87,6 +88,7 @@
|
|||||||
"@swc/helpers": "~0.5.18",
|
"@swc/helpers": "~0.5.18",
|
||||||
"@types/jest": "~30.0.0",
|
"@types/jest": "~30.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
"@types/ws": "^8.18.2",
|
"@types/ws": "^8.18.2",
|
||||||
"@typescript-eslint/utils": "^8.58.0",
|
"@typescript-eslint/utils": "^8.58.0",
|
||||||
"@vitest/coverage-v8": "~4.1.0",
|
"@vitest/coverage-v8": "~4.1.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user