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:
2026-10-08 09:19:59 +02:00
parent 0b65c1c912
commit efa06a0723
60 changed files with 3406 additions and 28 deletions

View File

@@ -4707,6 +4707,363 @@
],
"security": []
}
},
"/api/me/notifications": {
"get": {
"operationId": "NotificationsController_mine",
"parameters": [
{
"name": "unreadOnly",
"required": false,
"in": "query",
"description": "Only unread ones",
"schema": {
"default": false,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
}
},
{
"name": "limit",
"required": false,
"in": "query",
"schema": {
"minimum": 1,
"maximum": 100,
"default": 30,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MyNotificationsDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "My notifications, newest first",
"tags": [
"Users"
]
}
},
"/api/me/notifications/read": {
"post": {
"operationId": "NotificationsController_markRead",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MarkNotificationsReadDto"
}
}
}
},
"responses": {
"200": {
"description": "Done"
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Mark notifications as read (all of them without `ids`)",
"tags": [
"Users"
]
}
},
"/api/me/notification-settings": {
"get": {
"operationId": "NotificationsController_settings",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationSettingsResponseDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Whether I get copies by e-mail",
"tags": [
"Users"
]
},
"put": {
"operationId": "NotificationsController_updateSettings",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationSettingsDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationSettingsResponseDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Turn e-mail copies on or off",
"tags": [
"Users"
]
}
}
},
"info": {
@@ -6714,6 +7071,145 @@
"description": "Publish even with unfinished tickets"
}
}
},
"Object": {
"type": "object",
"properties": {}
},
"NotificationKind": {
"type": "string",
"enum": [
"ASSIGNED",
"STATUS_CHANGED",
"QA_FAILED",
"COMMENTED",
"MENTIONED",
"WATCHED",
"ADDED_TO_PROJECT",
"REMINDER"
]
},
"NotificationDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"kind": {
"allOf": [
{
"$ref": "#/components/schemas/NotificationKind"
}
]
},
"projectId": {
"type": "string",
"format": "uuid"
},
"projectKey": {
"type": "string",
"example": "WEB"
},
"issueId": {
"type": "string",
"nullable": true,
"format": "uuid"
},
"issueKey": {
"type": "string",
"nullable": true,
"example": "WEB-12"
},
"actor": {
"$ref": "#/components/schemas/UserRefDto"
},
"summary": {
"type": "string",
"example": "Ana te asignó WEB-12: Login roto en Safari"
},
"readAt": {
"type": "string",
"nullable": true,
"format": "date-time",
"description": "Null: unread"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"id",
"kind",
"projectId",
"projectKey",
"issueId",
"issueKey",
"actor",
"summary",
"readAt",
"createdAt"
]
},
"MyNotificationsDto": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationDto"
}
},
"unread": {
"type": "number",
"description": "All my unread ones, not just these"
}
},
"required": [
"items",
"unread"
]
},
"MarkNotificationsReadDto": {
"type": "object",
"properties": {
"ids": {
"description": "Omit to mark all of them",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"NotificationSettingsResponseDto": {
"type": "object",
"properties": {
"email": {
"type": "boolean",
"description": "Copies by e-mail"
},
"emailAvailable": {
"type": "boolean",
"description": "Whether this installation can send e-mail"
}
},
"required": [
"email",
"emailAvailable"
]
},
"NotificationSettingsDto": {
"type": "object",
"properties": {
"email": {
"type": "boolean",
"description": "Copies by e-mail"
}
},
"required": [
"email"
]
}
}
}

View File

@@ -342,6 +342,9 @@ type Mutation {
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
linkIssues(input: LinkIssuesInput!): CommandResult!
"""Mark these as read, or all of them when `ids` is omitted"""
markNotificationsRead(ids: [ID!]): Boolean!
"""
Ship the release. Fails while it has unfinished tickets unless force = true.
"""
@@ -357,6 +360,7 @@ type Mutation {
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
updateIssue(input: UpdateIssueInput!): CommandResult!
updateNotificationSettings(email: Boolean!): NotificationSettings!
updateProject(input: UpdateProjectInput!): CommandResult!
"""
@@ -366,6 +370,48 @@ type Mutation {
updateRelease(input: UpdateReleaseInput!): CommandResult!
}
type MyNotifications {
items: [Notification!]!
"""All my unread ones, not just these"""
unread: Int!
}
type Notification {
actor: UserRef!
createdAt: String!
id: ID!
issueId: ID
issueKey: String
kind: NotificationKind!
projectId: ID!
projectKey: String!
"""Null: unread"""
readAt: String
summary: String!
}
"""Why someone is told"""
enum NotificationKind {
ADDED_TO_PROJECT
ASSIGNED
COMMENTED
MENTIONED
QA_FAILED
REMINDER
STATUS_CHANGED
WATCHED
}
type NotificationSettings {
"""Copies by e-mail"""
email: Boolean!
"""Whether this installation can send e-mail at all"""
emailAvailable: Boolean!
}
type Project {
"""
Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA
@@ -498,8 +544,12 @@ type Query {
"""
myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage!
"""My notifications, newest first, and how many are unread"""
myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications!
"""Projects where I am a member"""
myProjects: [Project!]!
notificationSettings: NotificationSettings!
"""A project of mine, or any public one (no sign-in needed)"""
project(
@@ -603,6 +653,9 @@ type Subscription {
"""Projects I am added to / removed from"""
myProjectsChanged: ProjectChange!
"""My notifications, as they happen"""
notificationAdded: Notification!
projectChanged(projectId: ID!): ProjectChange!
releaseChanged(projectId: ID!): ReleaseChange!
}

View File

@@ -46,4 +46,10 @@ export const configSchema = Joi.object({
// Attached files: where they are kept (a volume in Docker) and how big one can be.
ATTACHMENTS_DIR: Joi.string().default('data/attachments'),
ATTACHMENTS_MAX_MB: Joi.number().min(1).max(500).default(25),
// E-mail copies of notifications (optional): without SMTP_URL nothing is sent.
SMTP_URL: Joi.string().allow('').optional(),
MAIL_FROM: Joi.string().allow('').optional(),
// Public address of the web app, for links in e-mails.
WEB_URL: Joi.string().allow('').optional(),
});